feat(android): add native controls and continuous multi-touch

This commit is contained in:
chenmingxuan committed 2026-11-02 21:33:45 +08:00
1 parent 3dd96078db
commit 4b12ae7728
70 files changed
+13105 -179

No files matched your search

@@ -0,0 +1,296 @@
package com.artemis.fixture;
import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.os.Bundle;
import android.text.Editable;
import android.text.InputType;
import android.text.TextWatcher;
import android.view.MotionEvent;
import android.view.View;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.inputmethod.EditorInfo;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.SeekBar;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
/** Disposable, dependency-free native controls and observable touch-event fixture. */
public final class MainActivity extends Activity {
private TextView status;
private TextView touchStatus;
private EditText editor;
private SeekBar range;
private TouchSurface touch;
private int clicks;
private int longClicks;
private int imeEnters;
private int statusVersion;
@Override public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setTitle("ARTEMIS native actions fixture");
ScrollView scroll = new ScrollView(this);
scroll.setContentDescription("fixture.scroll");
scroll.setFillViewport(true);
LinearLayout body = new LinearLayout(this);
body.setOrientation(LinearLayout.VERTICAL);
body.setPadding(dp(12), dp(12), dp(12), dp(24));
scroll.addView(body);
body.addView(label("ARTEMIS native controls", "fixture.title"));
status = label("status:ready", "fixture.status");
body.addView(status);
range = new SeekBar(this);
range.setContentDescription("fixture.range");
range.setMax(100);
range.setProgress(25);
body.addView(range, new LinearLayout.LayoutParams(-1, dp(52)));
range.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
updateStatus("range:" + progress);
}
@Override public void onStartTrackingTouch(SeekBar seekBar) {}
@Override public void onStopTrackingTouch(SeekBar seekBar) { writeState(); }
});
Button click = button("Native click", "fixture.click");
click.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) { clicks++; updateStatus("click:" + clicks); }
});
body.addView(click);
Button longClick = button("Native long click", "fixture.long_click");
longClick.setOnLongClickListener(new View.OnLongClickListener() {
@Override public boolean onLongClick(View view) {
longClicks++;
updateStatus("long_click:" + longClicks);
return true;
}
});
body.addView(longClick);
Button delayed = button("Change status after 700 ms", "fixture.delayed");
delayed.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) {
updateStatus("status:waiting");
status.postDelayed(new Runnable() {
@Override public void run() { statusVersion++; updateStatus("status:changed:" + statusVersion); }
}, 700L);
}
});
body.addView(delayed);
editor = new EditText(this) {
@Override protected void onSelectionChanged(int start, int end) {
super.onSelectionChanged(start, end);
writeState();
}
};
editor.setContentDescription("fixture.editor");
editor.setInputType(InputType.TYPE_CLASS_TEXT);
editor.setSingleLine(true);
editor.setImeOptions(EditorInfo.IME_ACTION_DONE);
editor.setText("A\uD83D\uDE00B中文");
editor.setSelection(editor.length());
editor.setOnEditorActionListener(new TextView.OnEditorActionListener() {
@Override public boolean onEditorAction(TextView view, int actionId, android.view.KeyEvent event) {
imeEnters++;
updateStatus("ime_enter:" + imeEnters);
return true;
}
});
editor.addTextChangedListener(new TextWatcher() {
@Override public void beforeTextChanged(CharSequence value, int start, int count, int after) {}
@Override public void onTextChanged(CharSequence value, int start, int before, int count) {}
@Override public void afterTextChanged(Editable value) { writeState(); }
});
body.addView(editor, new LinearLayout.LayoutParams(-1, dp(52)));
touchStatus = label("touch:ready", "fixture.touch_status");
body.addView(touchStatus);
touch = new TouchSurface();
body.addView(touch, new LinearLayout.LayoutParams(-1, dp(210)));
Button reset = button("Reset touch statistics", "fixture.touch_reset");
reset.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) { touch.reset(); writeState(); }
});
body.addView(reset);
// Native ScrollView actions must traverse multiple viewports to reach this target.
for (int i = 0; i < 45; i++) {
TextView row = label("Native list row " + i, "fixture.row." + i);
row.setMinHeight(dp(64));
body.addView(row);
}
Button bottom = button("Bottom target", "fixture.bottom");
bottom.setOnClickListener(new View.OnClickListener() {
@Override public void onClick(View view) { updateStatus("bottom:clicked"); }
});
body.addView(bottom);
setContentView(scroll);
writeState();
}
private int dp(int value) { return Math.round(value * getResources().getDisplayMetrics().density); }
private TextView label(String text, String description) {
TextView result = new TextView(this);
result.setText(text);
result.setTextSize(16);
result.setTextColor(Color.BLACK);
result.setPadding(0, dp(4), 0, dp(4));
result.setContentDescription(description);
return result;
}
private Button button(String text, String description) {
Button result = new Button(this);
result.setText(text);
result.setContentDescription(description);
return result;
}
private void updateStatus(String text) {
status.setText(text);
writeState();
}
/** Read explicitly with: adb shell run-as com.artemis.fixture cat files/native_actions_state.json */
private void writeState() {
if (editor == null || range == null || touch == null) return;
try {
JSONObject state = new JSONObject().put("click_count", clicks).put("long_click_count", longClicks)
.put("ime_enter_count", imeEnters).put("status", status.getText().toString())
.put("progress", range.getProgress()).put("text", editor.getText().toString())
.put("selection_start", editor.getSelectionStart()).put("selection_end", editor.getSelectionEnd())
.put("touch", touch.state(true));
try (FileOutputStream stream = openFileOutput("native_actions_state.json", MODE_PRIVATE)) {
stream.write(state.toString().getBytes(StandardCharsets.UTF_8));
}
} catch (Exception ignored) {}
}
private final class TouchSurface extends View {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private JSONArray events = new JSONArray();
private int downs;
private int ups;
private int cancels;
private int moves;
private int maxPointers;
private int pointerDowns;
private int pointerUps;
private double initialDistance;
private double initialAngle;
private double scale = 1.0;
private double rotation;
TouchSurface() {
super(MainActivity.this);
setContentDescription("fixture.touch");
setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
setFocusable(true);
setClickable(true);
setBackgroundColor(Color.rgb(208, 239, 247));
paint.setColor(Color.rgb(15, 45, 60));
paint.setTextSize(dp(16));
}
void reset() {
events = new JSONArray();
downs = ups = cancels = moves = maxPointers = pointerDowns = pointerUps = 0;
initialDistance = initialAngle = rotation = 0;
scale = 1;
publish();
}
JSONObject state(boolean includeEvents) throws Exception {
JSONObject result = new JSONObject().put("down_count", downs).put("up_count", ups)
.put("cancel_count", cancels).put("move_count", moves).put("max_pointer_count", maxPointers)
.put("pointer_down_count", pointerDowns).put("pointer_up_count", pointerUps)
.put("scale", scale).put("rotation_degrees", rotation);
if (includeEvents) result.put("events", events);
return result;
}
private void publish() {
try { touchStatus.setText(state(false).toString()); } catch (Exception ignored) {}
invalidate();
writeState();
}
@Override public boolean onTouchEvent(MotionEvent event) {
int action = event.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
downs++;
initialDistance = 0;
scale = 1;
rotation = 0;
getParent().requestDisallowInterceptTouchEvent(true);
} else if (action == MotionEvent.ACTION_UP) {
ups++;
getParent().requestDisallowInterceptTouchEvent(false);
} else if (action == MotionEvent.ACTION_CANCEL) {
cancels++;
getParent().requestDisallowInterceptTouchEvent(false);
} else if (action == MotionEvent.ACTION_MOVE) {
moves++;
} else if (action == MotionEvent.ACTION_POINTER_DOWN) {
pointerDowns++;
} else if (action == MotionEvent.ACTION_POINTER_UP) {
pointerUps++;
}
int count = event.getPointerCount();
maxPointers = Math.max(maxPointers, count);
if (count >= 2) {
double dx = event.getX(1) - event.getX(0);
double dy = event.getY(1) - event.getY(0);
double distance = Math.hypot(dx, dy);
double angle = Math.toDegrees(Math.atan2(dy, dx));
if (initialDistance == 0 || action == MotionEvent.ACTION_POINTER_DOWN) {
initialDistance = Math.max(distance, 0.001);
initialAngle = angle;
}
scale = distance / initialDistance;
rotation = angle - initialAngle;
while (rotation > 180) rotation -= 360;
while (rotation < -180) rotation += 360;
}
try {
JSONArray pointers = new JSONArray();
for (int i = 0; i < count; i++) {
pointers.put(new JSONObject().put("id", event.getPointerId(i))
.put("x", event.getX(i)).put("y", event.getY(i)));
}
if (events.length() >= 64) events.remove(0);
events.put(new JSONObject().put("action", action).put("time_ms", event.getEventTime())
.put("pointer_count", count).put("pointers", pointers));
} catch (Exception ignored) {}
publish();
return true;
}
@Override protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawText("Touch fixture: pinch, rotate, continuous stroke", dp(8), dp(30), paint);
canvas.drawText("Down " + downs + " / up " + ups + " / moves " + moves,
dp(8), dp(60), paint);
canvas.drawText("Pointers " + maxPointers + " / scale " + String.format(java.util.Locale.ROOT, "%.3f", scale),
dp(8), dp(90), paint);
canvas.drawText("Rotation " + String.format(java.util.Locale.ROOT, "%.1f", rotation), dp(8), dp(120), paint);
}
@Override public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
super.onInitializeAccessibilityNodeInfo(info);
info.setClassName("com.artemis.fixture.TouchSurface");
}
}
}
@@ -0,0 +1,298 @@
package com.artemis.helper;
import android.accessibilityservice.AccessibilityService;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.os.SystemClock;
import org.json.JSONObject;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import static com.artemis.helper.TestMain.*;
/** Offline scheduler/ownership tests against the real two production classes. */
public final class OwnershipCases {
private OwnershipCases() {}
private static void scenario(String name) {
TestMain.scenario = name;
}
private static void flushMain() throws Exception {
CountDownLatch flushed = new CountDownLatch(1);
new Handler(Looper.getMainLooper()).post(flushed::countDown);
check(flushed.await(2, TimeUnit.SECONDS), "main queue drained");
}
private static final class MainBlock implements AutoCloseable {
final CountDownLatch release = new CountDownLatch(1);
MainBlock() throws Exception {
CountDownLatch entered = new CountDownLatch(1);
new Handler(Looper.getMainLooper()).post(() -> {
entered.countDown();
try {
release.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
}
});
check(entered.await(2, TimeUnit.SECONDS), "main queue is blocked before the request");
}
@Override public void close() throws Exception {
release.countDown();
flushMain();
}
}
private static int tombstoneCount() throws Exception {
Field registration = TouchGestures.class.getDeclaredField("REGISTRATION");
registration.setAccessible(true);
Field cancelled = TouchGestures.class.getDeclaredField("CANCELLED");
cancelled.setAccessible(true);
synchronized (registration.get(null)) {
return ((Map<?, ?>) cancelled.get(null)).size();
}
}
private static void awaitOwner() throws Exception {
long end = System.currentTimeMillis() + 2000;
while (active() == null && System.currentTimeMillis() < end) Thread.sleep(1);
check(active() != null, "request acquired shared ownership");
}
private static boolean legacy(AccessibilityService service, String kind) {
switch (kind) {
case "tap": return GestureController.tap(service, 100, 200, 1000);
case "double": return GestureController.doubleTap(service, 100, 200, 500);
case "long": return GestureController.longPress(service, 100, 200, 500, 1000);
case "swipe": return GestureController.swipe(service, 100, 200, 200, 300, 100, 1000);
default: throw new AssertionError("Unknown legacy test action");
}
}
private static void cancellationBeforeRegistration() throws Exception {
scenario("cancellation precedes registration and is repeatable");
JSONObject first = cancel("cancel-before-registration");
code(first, "CANCEL_RECORDED", "observed");
eq(first.get("success"), true, "unmatched cancellation acknowledged");
code(cancel("cancel-before-registration"), "CANCEL_RECORDED", "observed");
AccessibilityService untouched = new AccessibilityService();
code(perform(untouched, spec("cancel-before-registration", 20, 2, 1)),
"CANCELLED", "not_started");
eq(untouched.calls, 0, "cancelled ID cannot dispatch when it arrives later");
waitQuiet();
scenario("completed request can be cancelled repeatedly without rewriting its result");
AccessibilityService completed = new AccessibilityService();
JSONObject original = perform(completed, spec("completed-before-cancel", 20, 2, 1));
code(original, "OK", "completed");
code(cancel("completed-before-cancel"), "CANCEL_RECORDED", "observed");
code(cancel("completed-before-cancel"), "CANCEL_RECORDED", "observed");
code(original, "OK", "completed");
AccessibilityService duplicate = new AccessibilityService();
code(perform(duplicate, spec("completed-before-cancel", 20, 2, 1)),
"CANCELLED", "not_started");
eq(duplicate.calls, 0, "repeated cancelled ID cannot inject new input");
}
private static void concurrentRegistrationAndCancellation() throws Exception {
for (int index = 0; index < 32; index++) {
scenario("concurrent registration/cancellation iteration " + index);
final String id = "registration-race-" + index;
final AccessibilityService service = new AccessibilityService();
final CountDownLatch race = new CountDownLatch(1);
Future<JSONObject> operation;
try (MainBlock block = new MainBlock()) {
operation = workers.submit(() -> {
race.await();
return perform(service, spec(id, 20, 2, 1));
});
Future<JSONObject> cancellation = workers.submit(() -> {
race.await();
return cancel(id);
});
race.countDown();
JSONObject acknowledgement = cancellation.get(2, TimeUnit.SECONDS);
String code = (String) acknowledgement.get("code");
check(code.equals("CANCEL_REQUESTED") || code.equals("CANCEL_RECORDED"),
"cancellation wins either registration ordering");
eq(service.calls, 0, "no input before blocked main is released");
}
code(operation.get(2, TimeUnit.SECONDS), "CANCELLED", "not_started");
eq(service.calls, 0, "no input after cancellation and main queue release");
waitQuiet();
}
}
private static void boundedCancellationHistory() throws Exception {
scenario("cancellation tombstones are bounded without unsafe eviction");
SystemClock.advance(60001);
AccessibilityService prune = new AccessibilityService();
code(perform(prune, spec("prune-old-cancellations", 20, 2, 1)), "OK", "completed");
eq(tombstoneCount(), 0, "expired tombstones pruned by registration");
for (int index = 0; index < 70; index++) {
code(cancel("capacity-" + index), "CANCEL_RECORDED", "observed");
check(tombstoneCount() <= 64, "retained cancellation IDs never exceed 64");
}
AccessibilityService evicted = new AccessibilityService();
code(perform(evicted, spec("capacity-0", 20, 2, 1)), "BUSY", "not_started");
eq(evicted.calls, 0, "eviction cannot revive a still-live cancelled request");
AccessibilityService retained = new AccessibilityService();
code(perform(retained, spec("capacity-69", 20, 2, 1)), "CANCELLED", "not_started");
eq(retained.calls, 0, "retained cancelled request cannot start");
AccessibilityService fresh = new AccessibilityService();
code(perform(fresh, spec("capacity-unrelated", 20, 2, 1)), "BUSY", "not_started");
eq(fresh.calls, 0, "capacity barrier blocks unrelated native dispatch");
check(!legacy(fresh, "tap"), "capacity barrier also blocks legacy input");
eq(fresh.calls, 0, "legacy cannot bypass the capacity barrier");
scenario("cancellation TTL expires without a real sixty-second wait");
SystemClock.advance(60001);
code(perform(fresh, spec("after-cancellation-ttl", 20, 2, 1)), "OK", "completed");
eq(tombstoneCount(), 0, "expired IDs no longer occupy bounded storage");
check(legacy(fresh, "tap"), "legacy ownership also recovers after the expiry barrier");
waitQuiet();
}
private static void bidirectionalOwnership() throws Exception {
scenario("active native gesture excludes every legacy gesture route");
final AccessibilityService nativeOwner = new AccessibilityService();
nativeOwner.dropOn = 1;
Future<JSONObject> nativePending = workers.submit(
() -> perform(nativeOwner, spec("native-excludes-legacy", 20, 2, 1)));
waitCalls(nativeOwner, 1);
flushMain();
AccessibilityService legacyOther = new AccessibilityService();
for (String kind : new String[]{"tap", "double", "long", "swipe"}) {
check(!legacy(legacyOther, kind), "native ownership blocks legacy " + kind);
}
eq(legacyOther.calls, 0, "no legacy dispatch bypasses native ownership");
nativeOwner.completeLast();
code(nativePending.get(2, TimeUnit.SECONDS), "OK", "completed");
waitQuiet();
for (String kind : new String[]{"tap", "double", "long", "swipe"}) {
scenario("legacy " + kind + " excludes native gesture until callback");
final AccessibilityService legacyOwner = new AccessibilityService();
legacyOwner.dropOn = 1;
Future<Boolean> pending = workers.submit(() -> legacy(legacyOwner, kind));
waitCalls(legacyOwner, 1);
flushMain();
AccessibilityService nativeOther = new AccessibilityService();
code(perform(nativeOther, spec("legacy-owner-" + kind, 20, 2, 1)), "BUSY", "not_started");
eq(nativeOther.calls, 0, "native cannot interrupt a legacy owner");
if (kind.equals("double")) {
eq(legacyOwner.lastGesture.strokes.size(), 2, "double tap is one two-stroke dispatch");
eq(legacyOwner.lastGesture.strokes.get(1).start, 160L, "second tap has the intended gap");
Thread.sleep(100);
check(active() != null, "double tap retains ownership through the inter-tap gap");
}
legacyOwner.completeLast();
check(pending.get(2, TimeUnit.SECONDS), "legacy callback reports completion");
eq(legacyOwner.calls, 1, "legacy action dispatched exactly once");
waitQuiet();
code(perform(nativeOther, spec("legacy-released-" + kind, 20, 2, 1)), "OK", "completed");
}
scenario("legacy API 24 stays available while continuous gestures remain gated");
Build.VERSION.SDK_INT = 24;
try {
AccessibilityService oldApi = new AccessibilityService();
check(legacy(oldApi, "tap"), "API 24 legacy tap still works");
code(perform(oldApi, spec("native-api24-gate", 20, 2, 1)), "UNSUPPORTED", "not_started");
eq(oldApi.calls, 1, "unsupported continuous gesture does not dispatch");
} finally {
Build.VERSION.SDK_INT = 36;
}
}
private static void queuedLegacyTimeoutAndInterruption() throws Exception {
scenario("legacy main-queued timeout cannot inject after main becomes available");
final AccessibilityService expired = new AccessibilityService();
try (MainBlock block = new MainBlock()) {
Future<Boolean> pending = workers.submit(() -> GestureController.tap(expired, 100, 200, 200));
awaitOwner();
check(!pending.get(2, TimeUnit.SECONDS), "queued legacy wait expires");
eq(expired.calls, 0, "timed-out queued input never started");
check(active() == null, "definitely unstarted timed-out owner is released");
}
eq(expired.calls, 0, "expired runnable cannot inject after main resumes");
check(legacy(expired, "tap"), "fresh legacy work can start after safe queued abandonment");
scenario("interrupted legacy wait abandons the queued runnable");
final AccessibilityService interrupted = new AccessibilityService();
try (MainBlock block = new MainBlock()) {
Future<Boolean> pending = workers.submit(() -> GestureController.tap(interrupted, 100, 200, 1000));
awaitOwner();
check(pending.cancel(true), "legacy worker interruption delivered");
waitQuiet();
}
eq(interrupted.calls, 0, "interrupted queued input cannot run later");
}
private static void unknownAndLateCallbackOwnership() throws Exception {
scenario("legacy timeout retains ownership until a late native callback proves quiet");
final AccessibilityService late = new AccessibilityService();
late.dropOn = 1;
Future<Boolean> pending = workers.submit(() -> GestureController.tap(late, 100, 200, 80));
waitCalls(late, 1);
check(!pending.get(2, TimeUnit.SECONDS), "legacy wait times out without a callback");
check(active() != null, "in-flight timeout keeps ownership");
AccessibilityService other = new AccessibilityService();
code(perform(other, spec("late-legacy-still-busy", 20, 2, 1)), "BUSY", "not_started");
check(!legacy(other, "swipe"), "legacy route cannot bypass the same unknown owner");
eq(other.calls, 0, "unknown input produces no replacement input");
late.completeLast();
waitQuiet();
eq(late.calls, 1, "late callback never replays movement");
code(perform(other, spec("late-legacy-released", 20, 2, 1)), "OK", "completed");
scenario("legacy dispatch exception retains BUSY without a callback");
AccessibilityService unknown = new AccessibilityService();
unknown.throwOn = 1;
check(!legacy(unknown, "tap"), "thrown dispatch does not report completion");
check(active() != null, "thrown dispatch remains owned");
AccessibilityService untouched = new AccessibilityService();
code(perform(untouched, spec("legacy-throw-busy", 20, 2, 1)), "BUSY", "not_started");
check(!legacy(untouched, "double"), "legacy input cannot bypass dispatch uncertainty");
eq(untouched.calls, 0, "unknown throw cannot cause replacement input");
resetUnknown(); // Test isolation only: production has no blind ownership reset.
scenario("late callback after a legacy dispatch exception releases ownership");
AccessibilityService throwsThenCompletes = new AccessibilityService();
throwsThenCompletes.throwAndCompleteOn = 1;
throwsThenCompletes.dropOn = 1;
check(!legacy(throwsThenCompletes, "tap"), "exception stays false even when callback arrives later");
check(active() != null, "throw awaits callback before releasing");
throwsThenCompletes.completeLast();
waitQuiet();
eq(throwsThenCompletes.calls, 1, "callback after throw does not resend input");
check(legacy(new AccessibilityService(), "tap"), "new input is admitted after confirmed callback");
scenario("uncertain native gesture excludes legacy input too");
AccessibilityService nativeUnknown = new AccessibilityService();
nativeUnknown.throwOn = 1;
code(perform(nativeUnknown, spec("unknown-native-legacy-gate", 20, 2, 1)), "DISPATCH_ERROR", "unknown");
check(active() != null, "native uncertainty retains shared ownership");
AccessibilityService legacyUntouched = new AccessibilityService();
check(!legacy(legacyUntouched, "tap"), "legacy cannot bypass uncertain native ownership");
eq(legacyUntouched.calls, 0, "native uncertainty admits no legacy dispatch");
resetUnknown();
}
public static void run() throws Exception {
cancellationBeforeRegistration();
concurrentRegistrationAndCancellation();
boundedCancellationHistory();
bidirectionalOwnership();
queuedLegacyTimeoutAndInterruption();
unknownAndLateCallbackOwnership();
}
}
+19
View File
@@ -0,0 +1,19 @@
# 原生能力隔离验收
夹具无网络与额外权限,包名 `com.artemis.fixture`,只记录自身控件值与触摸事件。需要已安装的 Android SDK/JDK,不会下载依赖。生成物写入 `artifacts/`,不提交设备输出。
1. `uv run python tests/native_actions_fixture/build.py --output artifacts/native-actions-fixture/fixture.apk`
2. 在指定且空闲设备安装该 APK;启动 `com.artemis.fixture/.MainActivity`。
3. 使用 ARTEMIS 探索该页面:`fixture.range`、`fixture.editor`、`fixture.scroll`、`fixture.bottom` 与 `fixture.touch`。
4. `uv run python tests/native_actions_fixture/verify_device.py --serial SERIAL --output artifacts/native-actions-fixture/verification --repeats 5`
5. 使用正式 Flash/Pro 复跑,并检查工具记录、APP 数值与最终验收;探索结果不能代替正式模式。
设备脚本获取现有设备锁,通过生产 driver/actuator 执行;会正常升级打包的辅助服务,并重建**自身测试 Activity**使计数可重复,不清空数据、不停止用户 APP。它验证精确设值、选区/替换/IME、条件等待、五轮缩放/旋转/连续接触及两轮往返滚动;软键盘关闭后再测试触摸区域。结果 JSON 保留每步回执,失败样本不删除。脚本保留夹具供取证,测试结束后由操作者恢复原 APP;需要卸载时仅卸载夹具。
`uv run python tests/native_actions_fixture/verify_cancellation.py --serial SERIAL --output artifacts/native-actions-fixture/cancellation` 验证先取消后注册、执行中的双指取消、实际释放及返回后没有新增触点。它要求夹具已在前台、触摸区与重置按钮完整可见、键盘关闭且无窗口遮挡;前置条件不足会写 blocked 报告,不自动修复界面。判断键盘以系统 `mInputShown` 为准,IME 内部 `mIsInputViewShown` 不能单独证明窗口可见。
`uv run python tests/native_actions_fixture/touch_jvm_harness.py` 在临时目录编译实际 Java 源码并模拟回调,验证取消、迟到回调与输入所有权。它不连接设备,不能证明 Android 的实际触摸交付。
`uv run python tests/native_actions_fixture/control_windows_jvm_harness.py` 编译生产窗口解析及原生控件实现,模拟多窗口唯一性、读失败、搜索预算、取消与写入遮挡。覆盖实际选区工具栏/手柄形状、区域空洞及多个遮挡合并;同样不操作设备。
构建脚本使用临时测试签名。重建后签名可能变化;如安装提示签名冲突,可卸载**这个夹具**再装,不卸载用户 APP 或 ARTEMIS 助手来绕过问题。
+151
View File
@@ -0,0 +1,151 @@
"""Build the disposable native fixture using an existing Android SDK/JDK only."""
import argparse
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import zipfile
def build(output: Path) -> Path:
sdk = Path(
os.environ.get("ANDROID_HOME")
or os.environ.get("ANDROID_SDK_ROOT")
or Path.home() / "AppData/Local/Android/Sdk"
)
versions = sorted(
(sdk / "build-tools").glob("*"),
key=lambda p: tuple(int(v) for v in p.name.split(".") if v.isdigit()),
)
platforms = sorted(
(sdk / "platforms").glob("android-*"), key=lambda p: int(p.name.split("-")[-1])
)
if not versions or not platforms:
raise RuntimeError("An existing Android SDK with platform and build-tools is required.")
tools = versions[-1]
android = platforms[-1] / "android.jar"
executable = ".exe" if os.name == "nt" else ""
def run(*args: object) -> None:
subprocess.run([str(arg) for arg in args], check=True, timeout=90)
java = shutil.which("java")
javac = shutil.which("javac")
keytool = shutil.which("keytool")
if not java or not javac or not keytool:
raise RuntimeError("An existing JDK (java, javac and keytool) is required.")
output = output.resolve()
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="artemis-native-fixture-") as temporary:
scratch = Path(temporary)
classes = scratch / "classes"
classes.mkdir()
manifest = scratch / "AndroidManifest.xml"
manifest.write_text(
"""<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.artemis.fixture">
<application android:label="ARTEMIS Native Fixture" android:debuggable="true" android:allowBackup="false" android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity android:name=".MainActivity" android:exported="true" android:windowSoftInputMode="adjustResize">
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
</activity>
</application>
</manifest>""",
encoding="utf-8",
)
unsigned = scratch / "unsigned.apk"
run(
tools / ("aapt2" + executable),
"link",
"-I",
android,
"--manifest",
manifest,
"--min-sdk-version",
26,
"--target-sdk-version",
35,
"--version-code",
1,
"--version-name",
"1.0",
"-o",
unsigned,
)
run(
javac,
"-encoding",
"UTF-8",
"-source",
8,
"-target",
8,
"-Xlint:-options",
"-cp",
android,
"-d",
classes,
Path(__file__).with_name("MainActivity.java"),
)
run(
java,
"-cp",
tools / "lib/d8.jar",
"com.android.tools.r8.D8",
"--lib",
android,
"--min-api",
26,
"--output",
scratch,
*classes.rglob("*.class"),
)
with zipfile.ZipFile(unsigned, "a", compression=zipfile.ZIP_DEFLATED) as archive:
archive.write(scratch / "classes.dex", "classes.dex")
aligned = scratch / "aligned.apk"
run(tools / ("zipalign" + executable), "-f", "-p", 4, unsigned, aligned)
key = scratch / "fixture.keystore"
# Public throwaway debug signing material, never reused for distribution.
run(
keytool,
"-genkeypair",
"-keystore",
key,
"-storepass",
"android",
"-keypass",
"android",
"-alias",
"fixture",
"-keyalg",
"RSA",
"-validity",
365,
"-dname",
"CN=ARTEMIS Test Fixture",
"-noprompt",
)
run(
java,
"-jar",
tools / "lib/apksigner.jar",
"sign",
"--ks",
key,
"--ks-pass",
"pass:android",
"--key-pass",
"pass:android",
"--ks-key-alias",
"fixture",
"--out",
output,
aligned,
)
return output
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
print(build(parser.parse_args().output))
@@ -0,0 +1,532 @@
"""Explicit offline native-window integration checks; requires an existing JDK only.
Run with ``uv run python tests/native_actions_fixture/control_windows_jvm_harness.py``.
Compiles the repository's actual NativeControls/ControlWindowRoots against small
Android fakes. No device, service, network, APK, or configured model is accessed.
Generated sources/classes live only in a self-cleaning temporary directory.
"""
from pathlib import Path
from tempfile import TemporaryDirectory
from time import perf_counter
from touch_jvm_harness import find_tool, run_command
STUBS = {
"android/os/Build.java": """
package android.os;
public final class Build {public static final class VERSION {public static int SDK_INT=36;}}
""",
"android/os/Looper.java": """
package android.os;
public final class Looper {private static final Looper MAIN=new Looper();
public static Looper getMainLooper(){return MAIN;}public static Looper myLooper(){return MAIN;}}
""",
"android/os/Handler.java": """
package android.os;
public final class Handler {public Handler(Looper l){}public boolean post(Runnable r){r.run();return true;}
public void removeCallbacks(Runnable r){}}
""",
"android/os/Bundle.java": """
package android.os;import java.util.*;
public final class Bundle {private final Map<String,Object> values=new HashMap<>();
public void putFloat(String k,float v){values.put(k,v);}public void putInt(String k,int v){values.put(k,v);}
public void putCharSequence(String k,CharSequence v){values.put(k,v);}public Object get(String k){return values.get(k);}}
""",
"android/graphics/Rect.java": """
package android.graphics;
public final class Rect {public int left,top,right,bottom;public Rect(){}
public Rect(int l,int t,int r,int b){left=l;top=t;right=r;bottom=b;}
public boolean isEmpty(){return left>=right||top>=bottom;}
public static boolean intersects(Rect a,Rect b){return a.left<b.right&&b.left<a.right&&a.top<b.bottom&&b.top<a.bottom;}
public String flattenToString(){return left+" "+top+" "+right+" "+bottom;}
@Override public boolean equals(Object o){if(!(o instanceof Rect))return false;Rect r=(Rect)o;
return left==r.left&&top==r.top&&right==r.right&&bottom==r.bottom;}
@Override public int hashCode(){return left+31*top+961*right+29791*bottom;}}
""",
"android/graphics/Region.java": """
package android.graphics;import java.awt.geom.Area;import java.awt.geom.Rectangle2D;
// Independent standard-JDK geometry implements the Android Region operations used by the real helper.
public final class Region {
public enum Op {INTERSECT,DIFFERENCE,UNION}
private Area area=new Area();public Region(){}public Region(Rect r){set(r);}
private static Area rect(Rect r){return r.isEmpty()?new Area():new Area(new Rectangle2D.Double(
r.left,r.top,(double)r.right-r.left,(double)r.bottom-r.top));}
public boolean set(Rect r){area=rect(r);return !isEmpty();}
public boolean set(Region r){area=new Area(r.area);return !isEmpty();}
public void setEmpty(){area=new Area();}public boolean isEmpty(){return area.isEmpty();}
public boolean op(Rect r,Op op){Region other=new Region(r);return op(other,op);}
public boolean op(Region r,Op op){Area other=new Area(r.area);
if(op==Op.INTERSECT)area.intersect(other);else if(op==Op.DIFFERENCE)area.subtract(other);else area.add(other);
return !isEmpty();}
@Override public boolean equals(Object value){if(!(value instanceof Region))return false;
Area difference=new Area(area);difference.exclusiveOr(((Region)value).area);return difference.isEmpty();}
@Override public int hashCode(){return 0;}
}
""",
"org/json/JSONArray.java": """
package org.json;import java.util.*;
public final class JSONArray {private final List<Object> values=new ArrayList<>();
public JSONArray put(Object v){values.add(v);return this;}public int length(){return values.size();}
public Object get(int i){return values.get(i);}public JSONObject getJSONObject(int i){return (JSONObject)get(i);}
@Override public String toString(){return values.toString();}}
""",
"org/json/JSONObject.java": """
package org.json;import java.util.*;
public final class JSONObject {public static final Object NULL=new Object(){public String toString(){return "null";}};
private final Map<String,Object> values=new LinkedHashMap<>();
public JSONObject put(String k,Object v){values.put(k,v==null?NULL:v);return this;}
public boolean has(String k){return values.containsKey(k);}public Object opt(String k){return values.get(k);}
public Object get(String k){if(!has(k))throw new IllegalArgumentException(k);return opt(k);}
public String getString(String k){return (String)get(k);}public int getInt(String k){return ((Number)get(k)).intValue();}
public boolean getBoolean(String k){return (Boolean)get(k);}
public JSONObject getJSONObject(String k){return (JSONObject)get(k);}
public JSONArray getJSONArray(String k){return (JSONArray)get(k);}
public JSONObject optJSONObject(String k){Object v=opt(k);return v instanceof JSONObject?(JSONObject)v:null;}
public JSONArray optJSONArray(String k){Object v=opt(k);return v instanceof JSONArray?(JSONArray)v:null;}
public boolean optBoolean(String k,boolean d){Object v=opt(k);return v instanceof Boolean?(Boolean)v:d;}
public Iterator<String> keys(){return values.keySet().iterator();}
@Override public String toString(){return values.toString();}}
""",
"android/view/accessibility/AccessibilityNodeInfo.java": """
package android.view.accessibility;
import android.graphics.Rect;import android.os.Bundle;import java.util.*;
public final class AccessibilityNodeInfo {
public static final int ACTION_CLICK=16,ACTION_LONG_CLICK=32,ACTION_FOCUS=1,ACTION_SCROLL_FORWARD=4096,
ACTION_SCROLL_BACKWARD=8192,ACTION_SET_SELECTION=131072,ACTION_SET_TEXT=2097152;
public static final String ACTION_ARGUMENT_PROGRESS_VALUE="progress",ACTION_ARGUMENT_SELECTION_START_INT="start",
ACTION_ARGUMENT_SELECTION_END_INT="end",ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE="text";
public static int live,acquired,recycled,doubleRecycle;
public static final class Data {
public final int identity,window;public String resource="",text="",pkg="app",cls="FrameLayout";
public boolean packageThrows,childThrows,editable,scrollSupport,disableAfterText,visible=true,enabled=true,refresh=true;
public int left=10,top=100,right=110,bottom=200,selectionStart=0,selectionEnd=0;
public int actionsPerformed;public Data parent;public final List<Data> children=new ArrayList<>();
public Data(int id,int win){identity=id;window=win;}
public Data add(Data child){children.add(child);if(child!=null)child.parent=this;return this;}
}
private final Data data;private boolean released;
public AccessibilityNodeInfo(Data d){data=d;live++;acquired++;}
public static AccessibilityNodeInfo obtain(AccessibilityNodeInfo n){n.valid();return new AccessibilityNodeInfo(n.data);}
private void valid(){if(released)throw new IllegalStateException("recycled node reused");}
public void recycle(){if(released){doubleRecycle++;throw new IllegalStateException("double recycle");}
released=true;live--;recycled++;}
public int getWindowId(){valid();return data.window;}
public CharSequence getPackageName(){valid();if(data.packageThrows)throw new IllegalStateException("package failed");return data.pkg;}
public CharSequence getViewIdResourceName(){valid();return data.resource;}
public CharSequence getClassName(){valid();return data.cls;}public CharSequence getText(){valid();return data.text;}
public CharSequence getContentDescription(){valid();return "";}
public int getChildCount(){valid();return data.children.size();}
public AccessibilityNodeInfo getChild(int index){valid();if(data.childThrows)throw new IllegalStateException("child failed");
Data d=data.children.get(index);return d==null?null:new AccessibilityNodeInfo(d);}
public AccessibilityNodeInfo getParent(){valid();return data.parent==null?null:new AccessibilityNodeInfo(data.parent);}
public void getBoundsInScreen(Rect r){valid();r.left=data.left;r.top=data.top;r.right=data.right;r.bottom=data.bottom;}
public boolean refresh(){valid();return data.refresh;}
public boolean performAction(int id,Bundle args){valid();data.actionsPerformed++;
if(id==ACTION_SET_TEXT){data.text=(String)args.get(ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE);
if(data.disableAfterText)data.enabled=false;}
if(id==ACTION_SET_SELECTION){data.selectionStart=(Integer)args.get(ACTION_ARGUMENT_SELECTION_START_INT);
data.selectionEnd=(Integer)args.get(ACTION_ARGUMENT_SELECTION_END_INT);}return true;}
public boolean isVisibleToUser(){valid();return data.visible;}public boolean isEnabled(){valid();return data.enabled;}
public boolean isEditable(){valid();return data.editable;}public boolean isPassword(){valid();return false;}
public boolean isChecked(){valid();return false;}public boolean isCheckable(){valid();return false;}
public boolean isSelected(){valid();return false;}public boolean isFocused(){valid();return false;}
public boolean isScrollable(){valid();return true;}public boolean isShowingHintText(){valid();return false;}
public int getTextSelectionStart(){valid();return data.selectionStart;}public int getTextSelectionEnd(){valid();return data.selectionEnd;}
public RangeInfo getRangeInfo(){valid();return null;}
public List<AccessibilityAction> getActionList(){valid();List<AccessibilityAction> actions=new ArrayList<>();
actions.add(new AccessibilityAction(ACTION_CLICK));if(data.editable){actions.add(new AccessibilityAction(ACTION_SET_TEXT));
actions.add(new AccessibilityAction(ACTION_SET_SELECTION));}if(data.scrollSupport)actions.add(new AccessibilityAction(ACTION_SCROLL_FORWARD));
return actions;}
@Override public boolean equals(Object o){return o instanceof AccessibilityNodeInfo
&& data.identity==((AccessibilityNodeInfo)o).data.identity&&data.window==((AccessibilityNodeInfo)o).data.window;}
@Override public int hashCode(){return data.identity*31+data.window;}
public static final class RangeInfo {public static final int RANGE_TYPE_INT=0;
public float getMin(){return 0;}public float getMax(){return 100;}public float getCurrent(){return 50;}public int getType(){return 0;}}
public static final class AccessibilityAction {private final int id;public AccessibilityAction(int i){id=i;}public int getId(){return id;}
public static final AccessibilityAction ACTION_SHOW_ON_SCREEN=new AccessibilityAction(16908342),
ACTION_SET_PROGRESS=new AccessibilityAction(16908349),ACTION_SCROLL_UP=new AccessibilityAction(16908344),
ACTION_SCROLL_DOWN=new AccessibilityAction(16908346),ACTION_SCROLL_LEFT=new AccessibilityAction(16908345),
ACTION_SCROLL_RIGHT=new AccessibilityAction(16908347),ACTION_IME_ENTER=new AccessibilityAction(16908372);}
}
""",
"android/view/accessibility/AccessibilityWindowInfo.java": """
package android.view.accessibility;import android.graphics.Rect;import android.graphics.Region;
public final class AccessibilityWindowInfo {
public static final int TYPE_APPLICATION=1,TYPE_INPUT_METHOD=2,TYPE_SYSTEM=3;
public static int live,recycled,doubleRecycle;
public static final class Data {
public final int id,layer;public int type=TYPE_APPLICATION,display;
public boolean active,rootThrows,metadataThrows,boundsThrow,regionThrows;
public int[][] regions;
public int left=0,top=0,right=1080,bottom=2400;
public AccessibilityNodeInfo.Data root;public Data(int i,int l,boolean a,AccessibilityNodeInfo.Data r){id=i;layer=l;active=a;root=r;}
}
private final Data data;private boolean released;
public AccessibilityWindowInfo(Data d){data=d;live++;}
private void valid(){if(released)throw new IllegalStateException("recycled window reused");
if(data.metadataThrows)throw new IllegalStateException("metadata unavailable");}
public int getId(){valid();return data.id;}public int getType(){valid();return data.type;}
public int getLayer(){valid();return data.layer;}public int getDisplayId(){valid();return data.display;}
public boolean isActive(){valid();return data.active;}
public void getBoundsInScreen(Rect r){valid();if(data.boundsThrow)throw new IllegalStateException("bounds failed");
r.left=data.left;r.top=data.top;r.right=data.right;r.bottom=data.bottom;}
public void getRegionInScreen(Region r){valid();if(data.regionThrows)throw new IllegalStateException("region failed");
if(data.regions==null){r.set(new Rect(data.left,data.top,data.right,data.bottom));return;}
r.setEmpty();for(int[] part:data.regions)r.op(new Rect(part[0],part[1],part[2],part[3]),Region.Op.UNION);}
public AccessibilityNodeInfo getRoot(){valid();if(data.rootThrows)throw new IllegalStateException("root failed");
return data.root==null?null:new AccessibilityNodeInfo(data.root);}
public void recycle(){if(released){doubleRecycle++;throw new IllegalStateException("double window recycle");}
released=true;live--;recycled++;}
}
""",
"android/accessibilityservice/AccessibilityService.java": """
package android.accessibilityservice;
import android.view.accessibility.*;import java.util.*;
public final class AccessibilityService {
public AccessibilityNodeInfo.Data active;public final List<AccessibilityWindowInfo.Data> windows=new ArrayList<>();
public boolean windowsThrow,windowsNull,activeThrows;public int windowReads;public Runnable beforeWindowRead;
public AccessibilityNodeInfo getRootInActiveWindow(){if(activeThrows)throw new IllegalStateException("active root failed");
return active==null?null:new AccessibilityNodeInfo(active);}
public List<AccessibilityWindowInfo> getWindows(){windowReads++;if(beforeWindowRead!=null)beforeWindowRead.run();
if(windowsThrow)throw new IllegalStateException("windows unavailable");
if(windowsNull)return null;List<AccessibilityWindowInfo> result=new ArrayList<>();
IdentityHashMap<AccessibilityWindowInfo.Data,AccessibilityWindowInfo> seen=new IdentityHashMap<>();
for(AccessibilityWindowInfo.Data d:windows){if(d==null){result.add(null);continue;}
AccessibilityWindowInfo w=seen.get(d);if(w==null){w=new AccessibilityWindowInfo(d);seen.put(d,w);}result.add(w);}return result;}
}
""",
"com/artemis/helper/ControlWindowsTest.java": """
package com.artemis.helper;
import android.accessibilityservice.AccessibilityService;import android.view.accessibility.*;
import android.os.Build;import java.util.concurrent.atomic.AtomicBoolean;import org.json.*;
public final class ControlWindowsTest {
static int checks,scenarios,nextId=1;static String scenario;
static void check(boolean v,String m){checks++;if(!v)throw new AssertionError(scenario+": "+m);}
static void eq(Object a,Object e,String m){check(e.equals(a),m+" actual="+a+" expected="+e);}
static AccessibilityNodeInfo.Data node(int window,String resource){AccessibilityNodeInfo.Data d=new AccessibilityNodeInfo.Data(nextId++,window);d.resource=resource;return d;}
static AccessibilityWindowInfo.Data window(AccessibilityNodeInfo.Data root,int layer,boolean active){return new AccessibilityWindowInfo.Data(root.window,layer,active,root);}
static AccessibilityService app(){AccessibilityService s=new AccessibilityService();s.active=node(217,"empty-shell");
s.windows.add(window(s.active,0,true));return s;}
static AccessibilityNodeInfo.Data add(AccessibilityService s,int id,int layer,String resource){
AccessibilityNodeInfo.Data d=node(id,resource);s.windows.add(window(d,layer,false));return d;}
static void rect(AccessibilityNodeInfo.Data node,int l,int t,int r,int b){node.left=l;node.top=t;node.right=r;node.bottom=b;}
static void rect(AccessibilityWindowInfo.Data window,int l,int t,int r,int b){window.left=l;window.top=t;window.right=r;window.bottom=b;}
static JSONObject args(String resource){return new JSONObject().put("selector",new JSONObject().put("resource_id",resource));}
static JSONObject inspect(AccessibilityService s,String resource){return NativeControls.inspect(s,args(resource));}
static JSONObject click(AccessibilityService s,String resource){return NativeControls.act(s,args(resource).put("action","click"));}
static void code(JSONObject r,String expected){eq(r.getString("code"),expected,"receipt code");}
static void begin(String name){quiet();scenario=name;scenarios++;}
static void quiet(){check(AccessibilityNodeInfo.live==0,"all node handles recycled: "+AccessibilityNodeInfo.live);
check(AccessibilityWindowInfo.live==0,"all window handles recycled: "+AccessibilityWindowInfo.live);
check(AccessibilityNodeInfo.doubleRecycle==0,"no node double recycle");
check(AccessibilityWindowInfo.doubleRecycle==0,"no window double recycle");}
public static void main(String[] ignored){
begin("real empty active shell with inactive upper player");AccessibilityService s=app();
AccessibilityNodeInfo.Data player=add(s,216,1,"player");player.add(node(216,"tv_timeline_date")).add(node(216,"tab_play_or_pause"));
JSONObject r=inspect(s,"tv_timeline_date");code(r,"OK");eq(r.getString("execution"),"observed","read only receipt");
eq(r.getJSONObject("node").getString("resource_id"),"tv_timeline_date","upper player resolved");
r=click(s,"tab_play_or_pause");code(r,"OK");eq(r.getString("execution"),"completed","write receipt");
eq(player.children.get(1).actionsPerformed,1,"exactly one upper-window action");
code(inspect(s,"empty-shell"),"OK");
begin("active modal excludes its lower application");s=app();s.windows.clear();s.windows.add(window(s.active,3,true));
AccessibilityNodeInfo.Data lower=add(s,216,1,"behind-dialog");code(inspect(s,"behind-dialog"),"NOT_FOUND");
code(click(s,"behind-dialog"),"NOT_FOUND");eq(lower.actionsPerformed,0,"no lower-window input");code(inspect(s,"empty-shell"),"OK");
begin("other packages, system and IME never become candidate controls");s=app();
AccessibilityNodeInfo.Data other=add(s,216,1,"outside");other.pkg="other.app";
AccessibilityNodeInfo.Data system=add(s,218,2,"outside");s.windows.get(2).type=AccessibilityWindowInfo.TYPE_SYSTEM;
s.windows.get(2).rootThrows=true;AccessibilityNodeInfo.Data ime=add(s,219,3,"outside");
s.windows.get(3).type=AccessibilityWindowInfo.TYPE_INPUT_METHOD;s.windows.get(3).rootThrows=true;
code(click(s,"outside"),"NOT_FOUND");eq(other.actionsPerformed+system.actionsPerformed+ime.actionsPerformed,0,"no foreign input");
begin("different display cannot share layer ordering");s=app();add(s,216,1,"outside");s.windows.get(1).display=7;
code(inspect(s,"outside"),"NOT_FOUND");
begin("API 24 window metadata remains usable");Build.VERSION.SDK_INT=24;s=app();add(s,216,1,"old-api");
code(inspect(s,"old-api"),"OK");Build.VERSION.SDK_INT=36;
begin("matches across active and upper roots are globally ambiguous");s=app();s.active.resource="duplicate";
AccessibilityNodeInfo.Data duplicate=add(s,216,1,"duplicate");r=click(s,"duplicate");code(r,"AMBIGUOUS");
eq(r.getInt("candidate_count"),2,"global count");eq(r.getJSONArray("candidates").length(),2,"global candidates");
eq(s.active.actionsPerformed+duplicate.actionsPerformed,0,"ambiguity performs no action");
begin("same-layer application still participates in uniqueness");s=app();s.active.resource="same-layer";
add(s,216,0,"same-layer");code(inspect(s,"same-layer"),"AMBIGUOUS");
begin("duplicate window handles and root snapshots do not invent ambiguity");s=app();add(s,216,1,"unique");
s.windows.add(s.windows.get(1));s.windows.add(new AccessibilityWindowInfo.Data(216,1,false,s.windows.get(1).root));
s.windows.add(s.windows.get(0));code(inspect(s,"unique"),"OK");code(click(s,"unique"),"OK");
eq(s.windows.get(1).root.actionsPerformed,1,"duplicate Region metadata permits only one native action");
begin("eligible unreadable roots invalidate an otherwise unique active match");s=app();
add(s,216,1,"anything");s.windows.get(1).root=null;code(click(s,"empty-shell"),"READ_FAILED");
eq(s.active.actionsPerformed,0,"unreadable candidate never permits fake uniqueness");code(inspect(s,"absent"),"READ_FAILED");
begin("candidate getRoot exception does not become absence");s=app();add(s,216,1,"anything");
s.windows.get(1).rootThrows=true;code(inspect(s,"absent"),"READ_FAILED");
begin("candidate package missing or unreadable fails closed");s=app();AccessibilityNodeInfo.Data bad=add(s,216,1,"anything");
bad.pkg=null;code(inspect(s,"absent"),"READ_FAILED");bad.pkg="";code(inspect(s,"absent"),"READ_FAILED");
bad.pkg="app";bad.packageThrows=true;code(inspect(s,"absent"),"READ_FAILED");
begin("candidate root identity changed during discovery");s=app();add(s,216,1,"anything");
s.windows.get(1).root=node(999,"anything");code(inspect(s,"absent"),"READ_FAILED");
begin("candidate child cannot be read");s=app();bad=add(s,216,1,"upper");bad.add(null);
code(inspect(s,"absent"),"READ_FAILED");bad.children.clear();bad.add(node(216,"child"));bad.childThrows=true;
code(inspect(s,"absent"),"READ_FAILED");
begin("scope resolves in upper window, target remains confined to its descendants");s=app();
AccessibilityNodeInfo.Data scope=add(s,216,1,"container");scope.add(node(216,"inside"));s.active.add(node(217,"outside"));
JSONObject scoped=args("inside").put("scope",new JSONObject().put("resource_id","container"));
code(NativeControls.inspect(s,scoped),"OK");scoped.put("selector",new JSONObject().put("resource_id","outside"));
code(NativeControls.inspect(s,scoped),"NOT_FOUND");
begin("scopes themselves must remain globally unique");s=app();s.active.resource="container";add(s,216,1,"container");
code(NativeControls.inspect(s,args("inside").put("scope",new JSONObject().put("resource_id","container"))),"AMBIGUOUS");
begin("candidate array remains capped across windows");s=app();s.active.resource="duplicate";
for(int i=0;i<12;i++)add(s,300+i,1,"duplicate");r=inspect(s,"duplicate");code(r,"AMBIGUOUS");
eq(r.getInt("candidate_count"),13,"all matches counted");eq(r.getJSONArray("candidates").length(),10,"candidate snapshot cap");
begin("node budget is shared across roots, not reset for each window");s=app();
AccessibilityNodeInfo.Data upper=add(s,216,1,"upper");for(int i=0;i<3000;i++){
s.active.add(node(217,"active-child"));upper.add(node(216,"upper-child"));}
code(inspect(s,"absent"),"LIMIT_EXCEEDED");
begin("search-limit failure cannot dispatch an early unique match");s.active.resource="unique";
code(click(s,"unique"),"LIMIT_EXCEEDED");eq(s.active.actionsPerformed,0,"bounded search proves no fake uniqueness");
begin("window enumeration has a hard upper bound with complete cleanup");s=app();
for(int i=0;i<65;i++)add(s,300+i,1,"upper");code(inspect(s,"absent"),"LIMIT_EXCEEDED");
begin("missing active root is unavailable, never absent");s=app();s.active=null;
code(inspect(s,"absent"),"UNAVAILABLE");
begin("empty, missing and unavailable metadata cannot prove uniqueness or absence");s=app();s.windows.clear();
code(inspect(s,"empty-shell"),"UNAVAILABLE");code(inspect(s,"absent"),"UNAVAILABLE");s.windowsNull=true;
code(inspect(s,"empty-shell"),"UNAVAILABLE");s.windowsNull=false;s.windowsThrow=true;code(inspect(s,"empty-shell"),"UNAVAILABLE");
begin("missing active window cannot broaden to an unrelated observed application");s=app();s.windows.clear();add(s,216,1,"upper");
code(inspect(s,"upper"),"UNAVAILABLE");code(inspect(s,"empty-shell"),"UNAVAILABLE");
begin("active metadata is not current");s=app();s.windows.get(0).active=false;add(s,216,1,"upper");
code(inspect(s,"upper"),"UNAVAILABLE");
begin("active metadata unavailable remains conservative");s=app();s.windows.get(0).metadataThrows=true;add(s,216,1,"upper");
code(inspect(s,"upper"),"UNAVAILABLE");
begin("declared candidate metadata missing after active metadata is established");s=app();s.windows.add(null);
code(inspect(s,"absent"),"READ_FAILED");
begin("single known application window permits one visible native action");s=app();
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"single-window input");
begin("invisible targets remain observable but cannot receive native actions");s=app();s.active.visible=false;
code(inspect(s,"empty-shell"),"OK");code(click(s,"empty-shell"),"NOT_VISIBLE");
eq(s.active.actionsPerformed,0,"invisible target never written");
begin("nonfocusable same-package higher modal blocks retained active root writes");s=app();add(s,216,1,"modal");
code(inspect(s,"empty-shell"),"OK");r=click(s,"empty-shell");code(r,"OCCLUDED");
eq(r.getString("execution"),"not_started","blocked before input");eq(s.active.actionsPerformed,0,"no modal penetration");
begin("any higher package or window type blocks full coverage");
for(int type:new int[]{1,2,3}){s=app();AccessibilityNodeInfo.Data blocker=add(s,216,1,"blocker");
blocker.pkg="other.app";s.windows.get(1).type=type;code(click(s,"empty-shell"),"OCCLUDED");
eq(s.active.actionsPerformed,0,"foreign/system/IME overlap blocked");}
begin("higher nonoverlapping status bar leaves the app target actionable");s=app();
add(s,216,1,"status-bar");s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;s.windows.get(1).bottom=48;
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"nonoverlapping system bar allowed");
begin("empty higher bounds do not claim occlusion");s=app();add(s,216,1,"empty-window");
s.windows.get(1).right=0;code(click(s,"empty-shell"),"OK");
begin("higher windows on another display do not occlude this display");s=app();add(s,216,1,"other-display");
s.windows.get(1).display=7;code(click(s,"empty-shell"),"OK");
begin("own window disappearance between resolution and action is unavailable");
final AccessibilityService removed=app();removed.beforeWindowRead=()->{if(removed.windowReads==2)removed.windows.clear();};
code(click(removed,"empty-shell"),"UNAVAILABLE");eq(removed.active.actionsPerformed,0,"missing context blocks input");
begin("a new overlapping window is checked immediately before action");
final AccessibilityService newlyCovered=app();newlyCovered.beforeWindowRead=()->{
if(newlyCovered.windowReads==2)add(newlyCovered,216,1,"new-modal");};
code(click(newlyCovered,"empty-shell"),"OCCLUDED");eq(newlyCovered.active.actionsPerformed,0,"new modal blocks input");
begin("latest metadata read failure never permits input");
final AccessibilityService latestFailure=app();latestFailure.beforeWindowRead=()->{
if(latestFailure.windowReads==2)latestFailure.windowsThrow=true;};
code(click(latestFailure,"empty-shell"),"UNAVAILABLE");eq(latestFailure.active.actionsPerformed,0,"failed latest query blocks input");
begin("unknown target window bounds are unavailable");s=app();s.windows.get(0).boundsThrow=true;
code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"unknown bounds block input");
begin("unknown active display cannot prove complete resolution");s=app();s.windows.get(0).display=-1;
code(inspect(s,"empty-shell"),"UNAVAILABLE");code(inspect(s,"absent"),"UNAVAILABLE");
begin("unknown potential occluder display cannot authorize input");s=app();add(s,216,1,"system-window");
s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;s.windows.get(1).display=-1;
code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"unknown display blocks input");
begin("empty target or own-window bounds cannot authorize input");s=app();s.active.right=s.active.left;
code(click(s,"empty-shell"),"UNAVAILABLE");s=app();s.windows.get(0).right=0;
code(click(s,"empty-shell"),"UNAVAILABLE");
begin("post-edit caret action rechecks latest windows and retains unknown partial receipt");
final AccessibilityService edit=app();edit.active.editable=true;edit.active.text="abc";
edit.beforeWindowRead=()->{if(edit.windowReads==3)add(edit,216,1,"new-overlay");};
r=NativeControls.act(edit,args("empty-shell").put("action","replace_selection").put("text","Z")
.put("selection_start",0).put("selection_end",1));code(r,"OCCLUDED");
eq(r.getString("execution"),"unknown","accepted text with blocked caret stays unknown");
eq(edit.active.text,"Zbc","accepted edit retained");eq(edit.active.actionsPerformed,1,"caret not dispatched through overlay");
begin("real code14 editor selection toolbar and handles leave editable area exposed");
s=new AccessibilityService();s.active=node(231,"activity");s.windows.add(window(s.active,0,true));
rect(s.windows.get(0),0,0,720,1600);AccessibilityNodeInfo.Data editor=node(231,"editor");
editor.editable=true;editor.text="A😀B中文";rect(editor,24,534,696,638);s.active.add(editor);
add(s,232,4,"selection-toolbar");rect(s.windows.get(1),32,446,554,542);
add(s,233,2,"selection-handle-left");rect(s.windows.get(2),1,611,76,691);
add(s,234,3,"selection-handle-right");rect(s.windows.get(3),77,611,165,691);
add(s,226,5,"status-bar");s.windows.get(4).type=AccessibilityWindowInfo.TYPE_SYSTEM;rect(s.windows.get(4),0,0,720,48);
add(s,218,1,"keyboard");s.windows.get(5).type=AccessibilityWindowInfo.TYPE_INPUT_METHOD;rect(s.windows.get(5),0,1002,720,1600);
r=NativeControls.act(s,args("editor").put("action","replace_selection").put("text"," X ")
.put("selection_start",1).put("selection_end",3));code(r,"OK");
eq(r.getString("execution"),"completed","partial occlusion permits native edit");eq(editor.text,"A X B中文","UTF-16 replacement retained");
eq(editor.actionsPerformed,2,"text and caret each accepted");eq(editor.selectionStart,4,"caret restored after inserted whitespace");
begin("partial higher overlap permits a native action on the exposed part");s=app();add(s,216,1,"partial-overlay");
rect(s.windows.get(1),0,0,60,300);code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"exposed part remains actionable");
begin("multiple higher windows jointly covering the target deny input");s=app();add(s,216,1,"cover-left");
rect(s.windows.get(1),0,0,60,300);add(s,218,2,"cover-right");rect(s.windows.get(2),60,0,200,300);
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"union coverage blocks input");
begin("one pixel gap in joint coverage remains exposed");s=app();add(s,216,1,"cover-left");
rect(s.windows.get(1),0,0,60,300);add(s,218,2,"cover-right");rect(s.windows.get(2),61,0,200,300);
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"integer region gap preserved");
begin("target area outside its own window cannot bypass full coverage inside");s=app();
rect(s.windows.get(0),10,100,60,200);add(s,216,1,"cover-visible-half");rect(s.windows.get(1),10,100,60,200);
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"own-window clipping precedes occlusion");
begin("overlap only outside the own-window clip does not block exposed area");s=app();
rect(s.windows.get(0),10,100,60,200);add(s,216,1,"outside-own-window");rect(s.windows.get(1),60,100,110,200);
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"outside clip is irrelevant");
begin("a target completely outside its own window is unavailable");s=app();rect(s.windows.get(0),0,0,5,50);
code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"invalid clip cannot authorize input");
begin("screen-sized container can scroll between system and IME edge windows");s=app();
rect(s.active,0,0,720,1600);rect(s.windows.get(0),0,0,720,1600);s.active.scrollSupport=true;
add(s,216,2,"status-bar");s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;rect(s.windows.get(1),0,0,720,48);
add(s,218,1,"keyboard");s.windows.get(2).type=AccessibilityWindowInfo.TYPE_INPUT_METHOD;rect(s.windows.get(2),0,1002,720,1600);
code(NativeControls.act(s,args("empty-shell").put("action","scroll_forward")),"OK");
eq(s.active.actionsPerformed,1,"visible middle permits native scroll");
begin("API30 own L-shaped region does not expose a target inside its bounding-box hole");Build.VERSION.SDK_INT=30;s=app();
rect(s.active,50,100,100,150);rect(s.windows.get(0),0,0,200,300);
s.windows.get(0).regions=new int[][]{{0,0,20,300},{0,0,200,50}};
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"own L-shape hole blocks input");Build.VERSION.SDK_INT=36;
begin("API30 own disconnected region cannot authorize a target in its gap");s=app();
rect(s.active,50,100,90,200);rect(s.windows.get(0),0,0,140,300);
s.windows.get(0).regions=new int[][]{{0,0,40,300},{100,0,140,300}};
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"own disconnected gap blocks input");
begin("API30 higher region hole leaves the target actionable despite bounding-box overlap");s=app();
add(s,216,1,"hollow-overlay");rect(s.windows.get(1),0,0,200,300);
s.windows.get(1).regions=new int[][]{{0,0,200,100},{0,200,200,300},{0,100,10,200},{110,100,200,200}};
code(click(s,"empty-shell"),"OK");eq(s.active.actionsPerformed,1,"actual overlay hole stays exposed");
begin("API30 disconnected higher regions jointly covering all target area deny input");s=app();
add(s,216,1,"alternating-a");rect(s.windows.get(1),10,100,85,200);
s.windows.get(1).regions=new int[][]{{10,100,35,200},{60,100,85,200}};
add(s,218,2,"alternating-b");rect(s.windows.get(2),35,100,110,200);
s.windows.get(2).regions=new int[][]{{35,100,60,200},{85,100,110,200}};
code(click(s,"empty-shell"),"OCCLUDED");eq(s.active.actionsPerformed,0,"union of disconnected regions blocks input");
begin("API29 uses conservative rectangular bounds without calling unsupported Region getter");
Build.VERSION.SDK_INT=29;s=app();add(s,216,1,"hollow-overlay");rect(s.windows.get(1),0,0,200,300);
s.windows.get(1).regions=new int[][]{{0,0,200,100},{0,200,200,300}};s.windows.get(1).regionThrows=true;
s.windows.get(0).regionThrows=true;code(click(s,"empty-shell"),"OCCLUDED");Build.VERSION.SDK_INT=36;
begin("API24 rectangular subtraction still permits partially exposed targets");Build.VERSION.SDK_INT=24;
s=app();add(s,216,1,"partial-overlay");rect(s.windows.get(1),0,0,60,300);
s.windows.get(0).regionThrows=true;s.windows.get(1).regionThrows=true;
code(click(s,"empty-shell"),"OK");Build.VERSION.SDK_INT=36;
begin("API30 failed Region metadata cannot silently fall back to rectangular bounds");s=app();
s.windows.get(0).regionThrows=true;code(click(s,"empty-shell"),"UNAVAILABLE");eq(s.active.actionsPerformed,0,"own Region failure blocks input");
s=app();add(s,216,1,"system-window");s.windows.get(1).type=AccessibilityWindowInfo.TYPE_SYSTEM;
s.windows.get(1).regionThrows=true;code(click(s,"empty-shell"),"UNAVAILABLE");
eq(s.active.actionsPerformed,0,"higher Region failure blocks input");
begin("post-edit disabled control prevents the caret action and preserves partial unknown");s=app();
s.active.editable=true;s.active.text="abc";s.active.disableAfterText=true;
r=NativeControls.act(s,args("empty-shell").put("action","replace_selection").put("text","Z")
.put("selection_start",0).put("selection_end",1));code(r,"DISABLED");eq(r.getString("execution"),"unknown","disabled caret is partial");
eq(s.active.text,"Zbc","accepted edit remains");eq(s.active.actionsPerformed,1,"disabled caret not dispatched");
begin("pre-cancelled discovery does not acquire input or roots");s=app();
try(ControlWindowRoots roots=ControlWindowRoots.capture(s,new AtomicBoolean(true))){
eq(roots.error,"CANCELLED","cancelled discovery");eq(roots.size(),0,"no root acquired");}
quiet();System.out.println("PASS "+checks+" checks across "+scenarios+" native-window scenarios");
}
}
""",
}
def main() -> int:
started = perf_counter()
try:
java, javac = find_tool("java"), find_tool("javac")
helper = (
Path(__file__).resolve().parents[2]
/ "packages/artemis-accessibility-helper/app/src/main/java/com/artemis/helper"
)
actual = [helper / name for name in ("NativeControls.java", "ControlWindowRoots.java")]
if not all(path.is_file() for path in actual):
raise RuntimeError("The repository native-control Java sources are missing")
print("Offline Android-window simulation against actual Java; no device input.", flush=True)
with TemporaryDirectory(prefix="artemis-control-windows-jvm-") as temporary:
workspace = Path(temporary)
classes = workspace / "classes"
classes.mkdir()
generated = []
for name, source in STUBS.items():
destination = workspace / "src" / name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(source.lstrip("\n"), encoding="utf-8")
generated.append(str(destination))
run_command(
[
javac,
"-encoding",
"UTF-8",
"-source",
"8",
"-target",
"8",
"-Xlint:-options",
"-d",
str(classes),
*generated,
*(str(path) for path in actual),
],
timeout=60,
)
run_command([java, "-cp", str(classes), "com.artemis.helper.ControlWindowsTest"], 30)
print(f"Native-window harness completed in {perf_counter() - started:.2f}s.")
return 0
except Exception as error:
print(f"FAIL: {error}")
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,278 @@
"""Manual JVM simulation of TouchGestures callbacks; no Android device is used.
Run explicitly from the repository root:
uv run python tests/native_actions_fixture/touch_jvm_harness.py
Requires a JDK (javac and java) through JAVA_HOME or PATH. Uses only the Python
standard library, generates fake Android classes in a temporary directory, and
compiles the repository's actual TouchGestures.java and GestureController.java.
It checks callback ownership,
cancellation, release, and error handling. It cannot prove real Android input,
MotionEvent delivery, visual results, or physical pointer continuity.
"""
from __future__ import annotations
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
from tempfile import TemporaryDirectory
from time import perf_counter
# These minimal fakes model callback scheduling, not Android's implementation.
STUBS: dict[str, str] = {
"android/accessibilityservice/AccessibilityService.java": r"""
package android.accessibilityservice;
import android.os.Handler;import android.content.Context;import java.util.*;
public class AccessibilityService extends Context {
public static final int GLOBAL_ACTION_BACK=1,GLOBAL_ACTION_HOME=2,GLOBAL_ACTION_RECENTS=3,GLOBAL_ACTION_NOTIFICATIONS=4,GLOBAL_ACTION_QUICK_SETTINGS=5,GLOBAL_ACTION_POWER_DIALOG=6,GLOBAL_ACTION_TOGGLE_SPLIT_SCREEN=7,GLOBAL_ACTION_LOCK_SCREEN=8,GLOBAL_ACTION_TAKE_SCREENSHOT=9;
public volatile int calls; public volatile int rejectOn,throwOn,cancelOn,dropOn,throwAndCompleteOn;public volatile boolean duplicate,earlyStationary;
public volatile List<GestureDescription.StrokeDescription> held;
public volatile GestureDescription lastGesture;private volatile GestureResultCallback lastCallback;private volatile Handler lastHandler;
public boolean performGlobalAction(int action){return false;}
private void emit(int n,GestureDescription gesture,GestureResultCallback callback){if(n==cancelOn){held=null;callback.onCancelled(gesture);}else{held=gesture.strokes.get(0).continuing?gesture.strokes:null;callback.onCompleted(gesture);if(duplicate)callback.onCompleted(gesture);}}
public void completeLast(){final GestureDescription gesture=lastGesture;final GestureResultCallback callback=lastCallback;lastHandler.post(()->emit(calls,gesture,callback));}
public boolean dispatchGesture(GestureDescription gesture,GestureResultCallback callback,Handler handler){
int n=++calls;
if(n==rejectOn)return false;
if(n==throwOn)throw new RuntimeException("mock dispatch uncertainty");
for(int i=0;i<gesture.strokes.size();i++){GestureDescription.StrokeDescription s=gesture.strokes.get(i);if(s.previous!=null&&(held==null||held.get(i)!=s.previous))throw new IllegalStateException("lost continuing contact");}
lastGesture=gesture;lastCallback=callback;lastHandler=handler;
long duration=0;for(GestureDescription.StrokeDescription stroke:gesture.strokes)duration=Math.max(duration,stroke.start+stroke.duration);
GestureDescription.StrokeDescription first=gesture.strokes.get(0);if(earlyStationary&&first.continuing&&first.path.x1==first.path.x2&&first.path.y1==first.path.y2)duration=0;
if(n!=dropOn)handler.postDelayed(()->emit(n,gesture,callback),duration+3);
if(n==throwAndCompleteOn)throw new RuntimeException("mock callback after throw");
return true;
}
public static class GestureResultCallback {public void onCompleted(GestureDescription d){}public void onCancelled(GestureDescription d){}}
}
""",
"android/accessibilityservice/GestureDescription.java": r"""
package android.accessibilityservice;
import android.graphics.Path; import java.util.*;
public class GestureDescription {
public final List<StrokeDescription> strokes; private GestureDescription(List<StrokeDescription> v){strokes=v;}
public static class Builder {final List<StrokeDescription> s=new ArrayList<>(); public Builder addStroke(StrokeDescription v){s.add(v);return this;}public GestureDescription build(){return new GestureDescription(new ArrayList<>(s));}}
public static class StrokeDescription { public final Path path;public final long start,duration; public final boolean continuing;public final StrokeDescription previous;
public StrokeDescription(Path p,long start,long d){this(p,start,d,false);}
public StrokeDescription(Path p,long start,long d,boolean c){this(p,start,d,c,null);if(start<0||d<1)throw new IllegalArgumentException();}
private StrokeDescription(Path p,long start,long d,boolean c,StrokeDescription prev){path=p;this.start=start;duration=d;continuing=c;previous=prev;}
public StrokeDescription continueStroke(Path p,long start,long d,boolean c){if(!continuing||start!=0||d<1||p.x1!=path.x2||p.y1!=path.y2)throw new IllegalArgumentException("invalid continuation");return new StrokeDescription(p,start,d,c,this);}
}
}
""",
"android/graphics/Path.java": r"""
package android.graphics; public class Path { public float x1,y1,x2,y2; public void moveTo(float x,float y){x1=x2=x;y1=y2=y;}public void lineTo(float x,float y){x2=x;y2=y;} }
""",
"android/os/Build.java": r"""
package android.os; public final class Build { public static final class VERSION { public static int SDK_INT=36; } public static final class VERSION_CODES {public static final int O=26,P=28;} }
""",
"android/os/Handler.java": r"""
package android.os;
import java.util.*; import java.util.concurrent.*;
public class Handler {
public static final ScheduledThreadPoolExecutor EXEC=new ScheduledThreadPoolExecutor(1,r->{Thread t=new Thread(r,"mock-main");t.setDaemon(true);return t;});
private static final Map<Runnable,List<Future<?>>> TASKS=new ConcurrentHashMap<>();
public Handler(Looper l){} public boolean post(Runnable r){return postDelayed(r,0);}
public boolean postDelayed(Runnable r,long delay){Future<?> f=EXEC.schedule(r,Math.max(0,delay),TimeUnit.MILLISECONDS);TASKS.computeIfAbsent(r,k->new CopyOnWriteArrayList<>()).add(f);return true;}
public void removeCallbacks(Runnable r){List<Future<?>> fs=TASKS.remove(r);if(fs!=null)for(Future<?> f:fs)f.cancel(false);}
}
""",
"android/os/Looper.java": r"""
package android.os; public final class Looper { private static final Looper MAIN=new Looper(); public static Looper getMainLooper(){return MAIN;} public static Looper myLooper(){return Thread.currentThread().getName().equals("mock-main")?MAIN:null;} }
""",
"android/os/SystemClock.java": r"""
package android.os; public final class SystemClock { private static volatile long offset;public static long uptimeMillis(){return System.nanoTime()/1000000L+offset;}public static void advance(long millis){offset+=millis;} }
""",
"android/os/Bundle.java": r"""
package android.os;public class Bundle {public void putCharSequence(String key,CharSequence value){}}
""",
"android/content/Context.java": r"""
package android.content;public class Context {public static final String CLIPBOARD_SERVICE="clipboard";public Object getSystemService(String name){return null;}}
""",
"android/content/ClipData.java": r"""
package android.content;public class ClipData {public static ClipData newPlainText(CharSequence label,CharSequence text){return new ClipData();}}
""",
"android/content/ClipboardManager.java": r"""
package android.content;public class ClipboardManager {public void setPrimaryClip(ClipData clip){}}
""",
"android/util/Log.java": r"""
package android.util;public final class Log {public static int w(String tag,String message){return 0;}public static int w(String tag,String message,Throwable error){return 0;}}
""",
"android/view/accessibility/AccessibilityNodeInfo.java": r"""
package android.view.accessibility;import android.os.Bundle;public class AccessibilityNodeInfo {public static final int ACTION_SET_TEXT=2097152;public static final String ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE="text";public CharSequence getText(){return null;}public boolean isShowingHintText(){return false;}public boolean performAction(int action,Bundle args){return false;}}
""",
"com/artemis/helper/HierarchyDumper.java": r"""
package com.artemis.helper;import android.accessibilityservice.AccessibilityService;import android.view.accessibility.AccessibilityNodeInfo;public final class HierarchyDumper {public static AccessibilityNodeInfo findInputNode(AccessibilityService service){return null;}public static void safeRecycle(AccessibilityNodeInfo node){}}
""",
"com/artemis/helper/DisplayUtils.java": r"""
package com.artemis.helper;import android.accessibilityservice.AccessibilityService;public final class DisplayUtils {public static volatile int width=1080,height=2400;public static class DisplayInfo {public int width,height;DisplayInfo(int w,int h){width=w;height=h;}}public static DisplayInfo getDisplayInfo(AccessibilityService s){return new DisplayInfo(width,height);}}
""",
"com/artemis/helper/NativeControls.java": r"""
package com.artemis.helper;import android.accessibilityservice.AccessibilityService;import org.json.JSONObject;public final class NativeControls {public static volatile JSONObject result;public static JSONObject inspect(AccessibilityService s,JSONObject args){return result;}}
""",
"com/artemis/helper/TestMain.java": r"""
package com.artemis.helper;
import android.accessibilityservice.AccessibilityService;import android.os.*;import org.json.*;import java.lang.reflect.*;import java.util.concurrent.*;import java.util.concurrent.atomic.*;
public final class TestMain {
static int checks;static String scenario="initialization";static ExecutorService workers=Executors.newCachedThreadPool();
static void check(boolean v,String message){checks++;if(!v)throw new AssertionError(scenario+": "+message);}
static void eq(Object actual,Object expected,String message){check(expected.equals(actual),message+": "+actual+" != "+expected);}
static JSONObject spec(String id,int interval,int frames,int fingers)throws Exception {JSONArray fs=new JSONArray();for(int f=0;f<frames;f++){JSONArray p=new JSONArray();for(int n=0;n<fingers;n++)p.put(new JSONArray().put(100+n*100+f*10).put(200+n*100+f*20));fs.put(new JSONObject().put("at_ms",f*interval).put("points",p));}return new JSONObject().put("operation_id",id).put("screen_size",new JSONArray().put(1080).put(2400)).put("frames",fs);}
static void code(JSONObject r,String c,String execution)throws Exception{eq(r.get("code"),c,"code");eq(r.get("execution"),execution,"execution");}
static JSONObject cancel(String id)throws Exception{return TouchGestures.cancel(new JSONObject().put("operation_id",id));}
static void waitCalls(AccessibilityService service,int calls)throws Exception {long end=System.currentTimeMillis()+2000;while(service.calls<calls&&System.currentTimeMillis()<end)Thread.sleep(1);check(service.calls>=calls,"dispatch started");}
static Object active()throws Exception{Field f=TouchGestures.class.getDeclaredField("ACTIVE");f.setAccessible(true);return ((AtomicReference<?>)f.get(null)).get();}
static void waitQuiet()throws Exception{long end=System.currentTimeMillis()+2000;while(active()!=null&&System.currentTimeMillis()<end)Thread.sleep(1);check(active()==null,"active ownership released");}
static void resetUnknown()throws Exception{Field f=TouchGestures.class.getDeclaredField("ACTIVE");f.setAccessible(true);((AtomicReference<?>)f.get(null)).set(null);}
static JSONObject perform(AccessibilityService s,JSONObject a){return TouchGestures.perform(s,a);}
public static void main(String[] args)throws Exception {
try{
scenario="normal synchronized continuation";AccessibilityService s=new AccessibilityService();JSONObject r=perform(s,spec("normal",30,4,2));code(r,"OK","completed");eq(r.get("completed_segments"),3,"completed");eq(r.get("dispatched_segments"),3,"dispatched");eq(r.get("fingers"),2,"fingers");check(s.held==null,"normal final release");waitQuiet();
scenario="duplicate callbacks";s=new AccessibilityService();s.duplicate=true;r=perform(s,spec("duplicate",20,3,1));code(r,"OK","completed");eq(s.calls,2,"duplicate callback no extra dispatch");eq(r.get("completed_segments"),2,"duplicate callback no extra completion");waitQuiet();
scenario="Android early stationary callback preserves hold";s=new AccessibilityService();s.earlyStationary=true;JSONObject hold=spec("hold-timing",250,4,1);hold.optJSONArray("frames").optJSONObject(1).put("points",new JSONArray().put(new JSONArray().put(100).put(200)));long holdStarted=SystemClock.uptimeMillis();r=perform(s,hold);code(r,"OK","completed");check(SystemClock.uptimeMillis()-holdStarted>=750,"hold is not compressed by callback");eq(s.calls,3,"no extra gesture during hold");check(s.held==null,"hold final release");waitQuiet();
scenario="invalid frame validation";s=new AccessibilityService();JSONObject invalid=spec("invalid",20,2,1);invalid.put("frames",new JSONArray().put(new JSONObject().put("at_ms",0).put("points",new JSONArray().put(new JSONArray().put(0).put(0)))).put(new JSONObject().put("at_ms",0).put("points",new JSONArray().put(new JSONArray().put(1).put(1)))));r=perform(s,invalid);code(r,"INVALID_ARGUMENT","not_started");eq(s.calls,0,"invalid untouched");
scenario="display-size mismatch";DisplayUtils.width=800;r=perform(s,spec("size",20,2,1));code(r,"DISPLAY_CHANGED","not_started");eq(s.calls,0,"size untouched");DisplayUtils.width=1080;
scenario="unsupported Android API";Build.VERSION.SDK_INT=24;r=perform(s,spec("api",20,2,1));code(r,"UNSUPPORTED","not_started");Build.VERSION.SDK_INT=36;
scenario="ambiguous visible guard";NativeControls.result=new JSONObject().put("success",false).put("code","AMBIGUOUS");JSONObject guarded=spec("guard",20,2,1).put("require_visible","fixture:id/touch");r=perform(s,guarded);code(r,"AMBIGUOUS","not_started");eq(s.calls,0,"guard untouched");waitQuiet();
scenario="visible guard acceptance";NativeControls.result=new JSONObject().put("success",true).put("node",new JSONObject().put("visible",true).put("enabled",true));r=perform(s,spec("guarded-ok",20,2,1).put("require_visible","fixture:id/touch"));code(r,"OK","completed");waitQuiet();
scenario="busy ownership and mid-segment cancellation";final AccessibilityService moving=new AccessibilityService();Future<JSONObject> pending=workers.submit(()->perform(moving,spec("cancel-mid",150,4,2)));waitCalls(moving,1);AccessibilityService other=new AccessibilityService();code(perform(other,spec("busy",20,2,1)),"BUSY","not_started");eq(other.calls,0,"busy untouched");code(cancel("wrong-id"),"CANCEL_RECORDED","observed");code(cancel("cancel-mid"),"CANCEL_REQUESTED","unknown");r=pending.get(2,TimeUnit.SECONDS);code(r,"CANCELLED","cancelled");eq(r.get("completed_segments"),1,"current interval completed");eq(r.get("dispatched_segments"),1,"later movement skipped");eq(moving.calls,2,"one endpoint release");check(moving.held==null,"cancel final release");waitQuiet();
scenario="queued cancellation before input";CountDownLatch block=new CountDownLatch(1),entered=new CountDownLatch(1);new Handler(Looper.getMainLooper()).post(()->{entered.countDown();try{block.await();}catch(Exception e){throw new RuntimeException(e);}});entered.await();final AccessibilityService queued=new AccessibilityService();pending=workers.submit(()->perform(queued,spec("queued",30,2,1)));long e=System.currentTimeMillis()+1000;while(active()==null&&System.currentTimeMillis()<e)Thread.sleep(1);cancel("queued");block.countDown();r=pending.get(2,TimeUnit.SECONDS);code(r,"CANCELLED","not_started");eq(queued.calls,0,"queued cancellation no input");waitQuiet();
scenario="first dispatch rejection";s=new AccessibilityService();s.rejectOn=1;r=perform(s,spec("rejected",20,2,1));code(r,"DISPATCH_REJECTED","not_started");eq(r.get("dispatched_segments"),0,"rejected receipt");waitQuiet();
scenario="Android cancellation callback";s=new AccessibilityService();s.cancelOn=1;r=perform(s,spec("os-cancel",20,3,1));code(r,"GESTURE_CANCELLED","cancelled");eq(r.get("completed_segments"),0,"OS cancelled incomplete");waitQuiet();
scenario="continuation rejection and endpoint release";s=new AccessibilityService();s.rejectOn=2;r=perform(s,spec("reject-continuation",20,3,2));code(r,"DISPATCH_REJECTED","cancelled");eq(s.calls,3,"rejection release once");eq(r.get("completed_segments"),1,"rejected no fake completion");check(s.held==null,"rejection release confirmed");waitQuiet();
scenario="continuation exception and endpoint release";s=new AccessibilityService();s.throwOn=2;r=perform(s,spec("throw-continuation",20,3,2));code(r,"DISPATCH_ERROR","unknown");eq(s.calls,3,"throw release once");check(s.held==null,"throw release confirmed");waitQuiet();
scenario="late callback after dispatch exception";s=new AccessibilityService();s.throwAndCompleteOn=1;r=perform(s,spec("late-callback",60,3,1));code(r,"DISPATCH_ERROR","unknown");check(active()!=null,"unknown keeps active while in flight");code(perform(new AccessibilityService(),spec("still-busy",20,2,1)),"BUSY","not_started");waitQuiet();check(s.held==null,"late callback release confirmed");eq(s.calls,2,"no movement retry after throw");
scenario="failed release retains ownership";final AccessibilityService releaseReject=new AccessibilityService();releaseReject.rejectOn=2;pending=workers.submit(()->perform(releaseReject,spec("release-reject",80,3,1)));waitCalls(releaseReject,1);cancel("release-reject");r=pending.get(2,TimeUnit.SECONDS);code(r,"RELEASE_REJECTED","unknown");check(active()!=null,"release failure retains active");code(perform(new AccessibilityService(),spec("blocked",20,2,1)),"BUSY","not_started");eq(releaseReject.calls,2,"release no retry");resetUnknown();
scenario="missing callback bounded timeout";s=new AccessibilityService();s.dropOn=1;long before=SystemClock.uptimeMillis();r=perform(s,spec("timeout",15,2,1));code(r,"TIMEOUT","unknown");long elapsed=SystemClock.uptimeMillis()-before;check(elapsed<5200,"bounded timeout "+elapsed);check(active()!=null,"callback timeout retains active");eq(s.calls,1,"callback timeout no retry");resetUnknown();
int baseline=checks;if(baseline<85)throw new AssertionError("Existing baseline checks were lost: "+baseline);
OwnershipCases.run();
System.out.println("BASELINE "+baseline+" existing checks; "+(checks-baseline)+" additional ownership checks");
System.out.println("PASS "+checks+" deterministic checks");
}finally{workers.shutdownNow();Handler.EXEC.shutdownNow();}
}
}
""",
"org/json/JSONArray.java": r"""
package org.json;import java.util.*;public class JSONArray {private final List<Object> v=new ArrayList<>();public JSONArray put(Object x){v.add(x);return this;}public int length(){return v.size();}public Object get(int i)throws Exception{return v.get(i);}public JSONObject optJSONObject(int i){Object x=v.get(i);return x instanceof JSONObject?(JSONObject)x:null;}public JSONArray optJSONArray(int i){Object x=v.get(i);return x instanceof JSONArray?(JSONArray)x:null;}public String toString(){return v.toString();}}
""",
"org/json/JSONObject.java": r"""
package org.json;import java.util.*;
public class JSONObject {
public static final Object NULL=new Object(){public String toString(){return "null";}};private final Map<String,Object> m=new LinkedHashMap<>();
public JSONObject put(String k,Object v)throws Exception{m.put(k,v==null?NULL:v);return this;}public boolean has(String k){return m.containsKey(k);}public Object opt(String k){return m.get(k);}public Object get(String k)throws Exception{if(!m.containsKey(k))throw new Exception(k);return m.get(k);}
public JSONObject optJSONObject(String k){Object v=m.get(k);return v instanceof JSONObject?(JSONObject)v:null;}public JSONArray optJSONArray(String k){Object v=m.get(k);return v instanceof JSONArray?(JSONArray)v:null;}
public boolean optBoolean(String k){return optBoolean(k,false);}public boolean optBoolean(String k,boolean d){Object v=m.get(k);return v instanceof Boolean?(Boolean)v:d;}
public String optString(String k,String d){Object v=m.get(k);return v instanceof String?(String)v:d;}public String toString(){return m.toString();}
}
""",
}
def find_tool(name: str) -> str:
java_home = os.environ.get("JAVA_HOME")
if java_home:
for suffix in (".exe", ""):
candidate = Path(java_home) / "bin" / f"{name}{suffix}"
if candidate.is_file():
return str(candidate)
resolved = shutil.which(name)
if resolved:
return resolved
raise RuntimeError(f"{name} was not found; set JAVA_HOME to a JDK or add it to PATH")
def run_command(arguments: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
arguments,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
check=False,
)
if result.stdout:
print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
if result.stderr:
print(result.stderr, end="" if result.stderr.endswith("\n") else "\n", file=sys.stderr)
if result.returncode:
raise RuntimeError(f"{Path(arguments[0]).name} exited with status {result.returncode}")
return result
def main() -> int:
started = perf_counter()
try:
javac = find_tool("javac")
java = find_tool("java")
repository = Path(__file__).resolve().parents[2]
actual_source = (
repository
/ "packages"
/ "artemis-accessibility-helper"
/ "app/src/main/java/com/artemis/helper/TouchGestures.java"
)
if not actual_source.is_file():
raise RuntimeError("The repository TouchGestures.java source is missing")
legacy_source = actual_source.with_name("GestureController.java")
ownership_cases = Path(__file__).with_name("OwnershipCases.java")
if not legacy_source.is_file() or not ownership_cases.is_file():
raise RuntimeError(
"The real GestureController.java or ownership regression cases are missing"
)
print(
"JVM simulation with fake Android callbacks; no device input is exercised.", flush=True
)
with TemporaryDirectory(prefix="artemis-touch-jvm-") as temporary:
workspace = Path(temporary)
sources = workspace / "src"
classes = workspace / "classes"
classes.mkdir()
generated: list[str] = []
for name, source in STUBS.items():
destination = sources / name
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(source.lstrip("\n"), encoding="utf-8")
generated.append(str(destination))
run_command(
[
javac,
"-J-Duser.language=en",
"-encoding",
"UTF-8",
"-source",
"8",
"-target",
"8",
"-Xlint:-options",
"-d",
str(classes),
*generated,
str(actual_source),
str(legacy_source),
str(ownership_cases),
],
timeout=45,
)
result = run_command(
[java, "-Duser.language=en", "-cp", str(classes), "com.artemis.helper.TestMain"],
timeout=45,
)
if not re.search(
r"^PASS [1-9]\d* deterministic checks$", result.stdout, flags=re.MULTILINE
):
raise RuntimeError("The JVM process returned without a deterministic check count")
print(f"Elapsed {perf_counter() - started:.2f}s (compile + JVM simulation)")
return 0
except (OSError, RuntimeError, subprocess.TimeoutExpired) as error:
print(f"FAIL: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,560 @@
"""Explicit device acceptance for cancellation on the already-explored fixture.
Run only when com.artemis.fixture is already foreground with its touch surface
and reset button fully visible, no keyboard, and no covering window:
uv run python tests/native_actions_fixture/verify_cancellation.py --serial SERIAL --output DIR
Writes DIR/result.json even on assertion or precondition failure. Uses the
production device lease, AccessibilityClient connection and AndroidAdbDriver
cancellation path. Does not launch/restart the fixture, dismiss UI, build an APK,
clear data, or replay input. --help never connects to a device.
"""
import argparse
import asyncio
import base64
from copy import deepcopy
from datetime import UTC, datetime
from io import BytesIO
import json
from pathlib import Path
import re
import subprocess
from threading import Lock
import time
from types import SimpleNamespace
from uuid import uuid4
from adbutils import AdbClient
from PIL import Image
from artemis.clients.accessibility_client import AccessibilityClient
from artemis.drivers.android.adb_driver import AndroidAdbDriver
from artemis.mcp.actuators.adb import AdbActuator
from artemis.runtime.adb_endpoint import adb_command
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
PACKAGE = "com.artemis.fixture"
STATE_FILE = "files/native_actions_state.json"
class PreconditionError(RuntimeError):
"""Current UI cannot safely exercise this already-explored fixture."""
def stamp() -> dict:
return {"utc": datetime.now(UTC).isoformat(), "monotonic": time.monotonic()}
class RecordingAccessibilityClient(AccessibilityClient):
"""Log real receipts, including the driver's otherwise internal cancel/drain."""
def __init__(self, serial: str) -> None:
super().__init__(serial)
self.receipts: list[dict] = []
self.receipt_lock = Lock()
def native_request(self, command: str, args: dict, *, timeout: float | None = None) -> dict:
record = {
"command": command,
"args": deepcopy(args),
"started": stamp(),
"timeout": timeout,
}
try:
receipt = super().native_request(command, args, timeout=timeout)
record["receipt"] = deepcopy(receipt)
return receipt
except BaseException as exc:
record["error"] = {"type": type(exc).__name__, "message": str(exc)}
raise
finally:
record["ended"] = stamp()
with self.receipt_lock:
self.receipts.append(record)
def receipt_snapshot(self) -> list[dict]:
with self.receipt_lock:
return deepcopy(self.receipts)
def adb_read(serial: str, *args: str) -> str:
return subprocess.run(
adb_command(["-s", serial, "shell", *args]),
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
timeout=5,
).stdout
def require(condition: bool, message: str) -> None:
if not condition:
raise PreconditionError(message)
def bounds(node: dict, width: int, height: int) -> list[int]:
rectangle = node.get("bounds")
require(
isinstance(rectangle, list)
and len(rectangle) == 4
and all(type(value) is int for value in rectangle),
"Native control has no exact pixel bounds; prepare the fixture manually.",
)
left, top, right, bottom = rectangle
require(
0 <= left < right <= width and 0 <= top < bottom <= height,
"Fixture touch/reset control is partially off screen; prepare it manually.",
)
return rectangle
def check_covering_windows(dump: str, rectangles: list[list[int]]) -> None:
"""WindowManager dumps windows top to bottom; refuse uncertain windows above the activity."""
windows = list(re.finditer(r"(?m)^\s*Window #\d+ (Window\{[^\n]+\}):\s*$", dump))
require(bool(windows), "Cannot read WindowManager z-order; covering windows are unknown.")
fixture = [
index
for index, window in enumerate(windows)
if PACKAGE + "/" in window.group(1) and "MainActivity" in window.group(1)
]
require(len(fixture) == 1, "Fixture activity window is missing or ambiguous.")
frame_pattern = r"\b(?:mFrame|frame)=\[(-?\d+),\s*(-?\d+)\]\[(-?\d+),\s*(-?\d+)\]"
for index, window in enumerate(windows[: fixture[0]]):
end = windows[index + 1].start()
block = dump[window.end() : end]
if "mHasSurface=false" in block:
continue
require(
"mHasSurface=true" in block,
f"Window above fixture has unknown surface state: {window.group(1)}",
)
visible = re.search(r"\bisOnScreen=(true|false)\b", block)
if visible is None:
visible = re.search(r"\bisVisible=(true|false)\b", block)
if visible is None:
visible = re.search(r"\bisReadyForDisplay\(\)=(true|false)\b", block)
if (visible is not None and visible.group(1) == "false") or "mViewVisibility=0x8" in block:
continue
frame = re.search(frame_pattern, block)
require(frame is not None, f"Window above fixture has unknown bounds: {window.group(1)}")
left, top, right, bottom = map(int, frame.groups())
overlap = any(
left < rect[2] and right > rect[0] and top < rect[3] and bottom > rect[1]
for rect in rectangles
)
require(
not overlap, f"A visible or uncertain window covers fixture controls: {window.group(1)}"
)
def gesture_args(width: int, height: int, rectangle: list[int]) -> dict:
left, top, right, bottom = rectangle
require(right - left >= 160 and bottom - top >= 100, "Observed touch surface is too small.")
cx, cy = (left + right) // 2, (top + bottom) // 2
span = (right - left) // 6
dx, dy = (right - left) // 12, (bottom - top) // 12
offsets = [(0, 0), (dx, -dy), (0, dy), (-dx, -dy), (0, 0)]
return {
"operation_id": str(uuid4()),
"screen_size": [width, height],
"frames": [
{"at_ms": index * 400, "points": [[cx - span + x, cy + y], [cx + span + x, cy + y]]}
for index, (x, y) in enumerate(offsets)
],
}
async def verify(serial: str, output: Path) -> None:
output.mkdir(parents=True, exist_ok=True)
report = {
"serial": serial,
"fixture_package": PACKAGE,
"started": stamp(),
"status": "running",
"checks": [],
"records": [],
"cleanup_errors": [],
}
client = RecordingAccessibilityClient(serial)
lease = DeviceExecutionLock(serial, description="Native fixture cancellation acceptance")
connection_attempted = False
gesture_task: asyncio.Task | None = None
def check(condition: bool, label: str) -> None:
report["checks"].append({"check": label, "passed": bool(condition), "at": stamp()})
if not condition:
raise AssertionError(label)
async def state(label: str) -> dict:
# MainActivity writes in place after MotionEvents; only JSON decoding is retried.
for attempt in range(5):
raw = await asyncio.to_thread(adb_read, serial, "run-as", PACKAGE, "cat", STATE_FILE)
try:
value = json.loads(raw)
except json.JSONDecodeError as exc:
report["records"].append(
{
"state_read": label,
"attempt": attempt + 1,
"decode_error": str(exc),
"at": stamp(),
}
)
if attempt == 4:
raise
await asyncio.sleep(0.02)
continue
if not isinstance(value, dict) or not isinstance(value.get("touch"), dict):
raise ValueError("Fixture state file has no touch object.")
report["records"].append({"state_read": label, "state": value, "at": stamp()})
return value
raise AssertionError("Unreachable state retry exit")
async def unchanged(expected: dict, label: str, duration: float) -> None:
deadline = time.monotonic() + duration
while True:
observed = await state(label)
check(observed["touch"] == expected, label)
if time.monotonic() >= deadline:
return
await asyncio.sleep(min(0.05, max(0, deadline - time.monotonic())))
try:
lease.acquire(blocking=False)
connection_attempted = True
await asyncio.to_thread(client.connect)
session = client.session
require(
session is not None and session.protocol_version >= 3, "Helper protocol 3 is required."
)
require(
{"inspect_control", "control_action", "touch_gesture", "cancel_gesture"}
<= client.native_capabilities,
"Connected helper lacks the required native capabilities; upgrade separately.",
)
report["helper"] = {"version": session.version_name, "code": session.version_code}
screen = await asyncio.to_thread(client.get_screen_data)
with Image.open(BytesIO(base64.b64decode(screen.base64))) as image:
width, height = image.size
report["screen_size"] = [width, height]
driver = AndroidAdbDriver(serial, AdbClient(), client, width, height)
actuator = AdbActuator(
SimpleNamespace(
device=SimpleNamespace(device_width=width, device_height=height), data_engine=None
),
SimpleNamespace(driver=driver, native_request=driver.native_request),
)
async def tool(name: str, **args) -> dict:
started = stamp()
result = await getattr(actuator, name)(**args)
report["records"].append(
{
"tool": name,
"args": args,
"result": result.model_dump(mode="json"),
"started": started,
"ended": stamp(),
}
)
if name == "read_control":
require(result.ok, f"Native {name} refused: {result.message}")
else:
check(result.ok, f"Native {name} accepted: {result.message}")
return result.metadata
async def preconditions() -> tuple[dict, dict]:
foreground = await driver.get_current_package()
require(
foreground == PACKAGE,
"Fixture is not foreground; no UI will be launched or repaired.",
)
ime = await asyncio.to_thread(adb_read, serial, "dumpsys", "input_method")
ime_flags = re.findall(r"\b(?:mInputShown|mIsInputViewShown)=(?:true|false)\b", ime)
report["records"].append(
{"foreground": foreground, "ime_flags": ime_flags, "at": stamp()}
)
# IME-internal mIsInputViewShown may stay true for a hidden window.
# Only the system's unique mInputShown value establishes visibility.
system_input_shown = re.findall(r"\bmInputShown=(true|false)\b", ime)
require(
system_input_shown == ["false"],
"Keyboard is visible or visibility is unknown; prepare the fixture manually.",
)
nodes = []
for desc in ("fixture.touch", "fixture.touch_reset"):
node = (
await tool(
"read_control", selector={"package_name": PACKAGE, "content_desc": desc}
)
)["node"]
require(
node.get("package_name") == PACKAGE
and node.get("visible") is True
and node.get("enabled") is True,
f"{desc} is not a visible enabled fixture control.",
)
bounds(node, width, height)
nodes.append(node)
require(
"click" in nodes[1].get("actions", []), "Reset control lacks a native click action."
)
windows = await asyncio.to_thread(adb_read, serial, "dumpsys", "window", "windows")
report["records"].append(
{
"window_diagnostics": [
line
for line in windows.splitlines()
if re.search(
r"Window #|mCurrentFocus|mHasSurface|isOnScreen|Frames:|mFrame=", line
)
],
"at": stamp(),
}
)
require(
not re.search(r"\b(?:mShowingLockscreen|mDreamingLockscreen)=true\b", windows),
"A lock screen covers the fixture.",
)
check_covering_windows(windows, [node["bounds"] for node in nodes])
require(
await driver.get_current_package() == PACKAGE,
"Foreground changed during preflight.",
)
return nodes[0], nodes[1]
async def reset() -> tuple[dict, dict]:
_, button = await preconditions()
previous = (await state("before native reset"))["touch"]
require(
previous.get("down_count")
== previous.get("up_count", 0) + previous.get("cancel_count", 0),
"The fixture already has a held touch; do not reset its live counters.",
)
await tool(
"control_action",
selector={"package_name": PACKAGE, "content_desc": "fixture.touch_reset"},
action="click",
expected_token=button["token"],
)
baseline = await state("after native reset")
check(
all(
baseline["touch"].get(key) == 0
for key in (
"down_count",
"up_count",
"cancel_count",
"move_count",
"max_pointer_count",
"pointer_down_count",
"pointer_up_count",
)
),
"native reset cleared app touch counters",
)
check(baseline["touch"].get("events") == [], "native reset cleared app event history")
# Reset publishes a status label and may relayout the activity.
touch, _ = await preconditions()
return touch, baseline
touch, baseline = await reset()
late_args = gesture_args(width, height, touch["bounds"])
cancelled_id = late_args["operation_id"]
for label in ("cancel before registration", "repeated cancel before registration"):
receipt = await driver.native_request(
"cancel_gesture", {"operation_id": cancelled_id}, timeout=2
)
check(
receipt.get("success") is True
and receipt.get("code") == "CANCEL_RECORDED"
and receipt.get("execution") == "observed",
label,
)
receipt = await driver.native_request("touch_gesture", late_args, timeout=6.6)
check(
receipt.get("success") is False
and receipt.get("code") == "CANCELLED"
and receipt.get("execution") == "not_started"
and receipt.get("dispatched_segments") == 0,
"late same-ID gesture rejected before any input",
)
receipt = await driver.native_request(
"cancel_gesture", {"operation_id": cancelled_id}, timeout=2
)
check(
receipt.get("success") is True
and receipt.get("code") == "CANCEL_RECORDED"
and receipt.get("execution") == "observed",
"cancel remains safe after rejected request",
)
await unchanged(
baseline["touch"], "pre-registration cancellation leaves app untouched", 0.15
)
touch, baseline = await reset()
active_args = gesture_args(width, height, touch["bounds"])
active_id = active_args["operation_id"]
report["records"].append({"gesture_task_started": stamp(), "operation_id": active_id})
gesture_task = asyncio.create_task(
driver.native_request("touch_gesture", active_args, timeout=6.6)
)
down_deadline = time.monotonic() + 1.2
while True:
down = (await state("observe app down before Python cancellation"))["touch"]
if down.get("down_count") == 1 and down.get("max_pointer_count") == 2:
check(
down.get("up_count") == 0 and down.get("cancel_count") == 0,
"cancel only while app has an active two-finger contact",
)
break
check(
not gesture_task.done() and time.monotonic() < down_deadline,
"app observes two-finger down before the gesture completes",
)
await asyncio.sleep(0.025)
check(not gesture_task.done(), "production native request still pending at cancellation")
cancel_at = stamp()
report["records"].append(
{"python_task_cancel_requested": cancel_at, "operation_id": active_id}
)
gesture_task.cancel()
try:
await gesture_task
except asyncio.CancelledError:
current = asyncio.current_task()
if current is not None and current.cancelling():
raise
cancelled_at = stamp()
report["records"].append(
{"python_cancelled_error_observed": cancelled_at, "operation_id": active_id}
)
else:
raise AssertionError("Production driver did not propagate CancelledError")
receipts = [
item
for item in client.receipt_snapshot()
if item["args"].get("operation_id") == active_id
]
original = [item for item in receipts if item["command"] == "touch_gesture"]
cancellations = [item for item in receipts if item["command"] == "cancel_gesture"]
check(
len(original) == len(cancellations) == 1,
"driver sends one gesture and one cancellation without replay",
)
final_receipt = original[0].get("receipt", {})
check(
final_receipt.get("code") == "CANCELLED"
and final_receipt.get("execution") == "cancelled"
and 0 < final_receipt.get("dispatched_segments", 0) < len(active_args["frames"]) - 1,
"driver drains the real interrupted gesture receipt",
)
check(
cancellations[0].get("receipt", {}).get("code") == "CANCEL_REQUESTED",
"driver cancellation reaches the active native gesture",
)
check(
original[0]["ended"]["monotonic"] <= cancelled_at["monotonic"],
"CancelledError is observed only after original native request drains",
)
stopped = (await state("immediately after CancelledError"))["touch"]
check(
stopped.get("down_count") == stopped.get("up_count") == 1
and stopped.get("cancel_count") == 0
and stopped.get("max_pointer_count") == 2,
"app received one two-finger down/up and no cancel",
)
check(
stopped.get("pointer_down_count") == stopped.get("pointer_up_count") == 1,
"both native pointers are released exactly once",
)
await unchanged(stopped, "no new app input for 500ms after CancelledError", 0.5)
touch, _ = await preconditions()
for invalid in ("out_of_bounds", "nonincreasing_time"):
args = gesture_args(width, height, touch["bounds"])
if invalid == "out_of_bounds":
args["frames"][-1]["points"][0][0] = width
else:
args["frames"][-1]["at_ms"] = args["frames"][-2]["at_ms"]
receipt = await driver.native_request("touch_gesture", args, timeout=6.6)
check(
receipt.get("success") is False
and receipt.get("code") == "INVALID_ARGUMENT"
and receipt.get("execution") == "not_started"
and receipt.get("dispatched_segments") == 0,
f"raw {invalid} gesture rejected before input",
)
await unchanged(
stopped, f"raw {invalid} rejection leaves app touch state unchanged", 0.1
)
report["status"] = "passed"
except BaseException as exc:
report["status"] = (
"blocked"
if isinstance(exc, (PreconditionError, DeviceBusyError))
else "cancelled"
if isinstance(exc, (asyncio.CancelledError, KeyboardInterrupt))
else "failed"
)
report["error"] = {"type": type(exc).__name__, "message": str(exc)}
raise
finally:
# Never release the device lease while the production driver still drains input.
if gesture_task is not None and not gesture_task.done():
gesture_task.cancel()
while not gesture_task.done():
try:
await asyncio.shield(gesture_task)
except asyncio.CancelledError:
continue
except Exception as exc:
report["cleanup_errors"].append({"stage": "drain gesture", "error": str(exc)})
break
if connection_attempted:
try:
await state("finally")
except BaseException as exc:
report["cleanup_errors"].append({"stage": "final state", "error": str(exc)})
try:
await asyncio.to_thread(client.disconnect)
except BaseException as exc:
report["cleanup_errors"].append({"stage": "disconnect", "error": str(exc)})
try:
lease.release()
except Exception as exc:
report["cleanup_errors"].append({"stage": "lease release", "error": str(exc)})
if report["cleanup_errors"] and report["status"] == "passed":
report["status"] = "failed"
report["native_receipts"] = client.receipt_snapshot()
report["ended"] = stamp()
report["elapsed_seconds"] = report["ended"]["monotonic"] - report["started"]["monotonic"]
(output / "result.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8"
)
if report["status"] != "passed":
raise RuntimeError("Acceptance cleanup failed; see result.json.")
print(
f"PASS {len(report['checks'])} checks; receipts and app state: {output / 'result.json'}",
flush=True,
)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
"--serial", required=True, help="Explicit already-explored Android device serial"
)
parser.add_argument(
"--output", required=True, type=Path, help="Artifact directory; report is result.json"
)
options = parser.parse_args()
if not options.serial.strip():
parser.error("--serial cannot be blank")
asyncio.run(verify(options.serial, options.output))
@@ -0,0 +1,445 @@
"""Explicit device acceptance for the already-explored isolated native fixture.
Run after building/installing MainActivity and exploring it through ARTEMIS:
uv run python tests/native_actions_fixture/verify_device.py --serial SERIAL --output DIR
Uses the existing device lease, driver and actuator. Never resets app data or
stops another app. Artifacts contain fixture state and actual native receipts.
"""
import argparse
import asyncio
import base64
from io import BytesIO
import json
import math
from pathlib import Path
import subprocess
import time
from types import SimpleNamespace
from adbutils import AdbClient
from PIL import Image
from artemis.clients.accessibility_client import AccessibilityClient
from artemis.drivers.android.adb_driver import AndroidAdbDriver
from artemis.mcp.actuators.adb import AdbActuator
from artemis.runtime.adb_endpoint import adb_command
from artemis.runtime.device_lock import DeviceExecutionLock
async def verify(serial: str, output: Path, repeats: int) -> None:
output.mkdir(parents=True, exist_ok=True)
lease = DeviceExecutionLock(serial, description="Native fixture acceptance")
lease.acquire(blocking=False)
client = AccessibilityClient(serial)
records = []
checks = []
started = time.monotonic()
def check(condition, label):
checks.append({"check": label, "passed": bool(condition)})
if not condition:
raise AssertionError(label)
def state():
result = subprocess.run(
adb_command(
[
"-s",
serial,
"shell",
"run-as",
"com.artemis.fixture",
"cat",
"files/native_actions_state.json",
]
),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
timeout=10,
)
return json.loads(result.stdout)
try:
await asyncio.to_thread(client.connect)
session = client.session
check(session is not None and session.protocol_version >= 3, "helper protocol 3")
# Recreate only this isolated test Activity, so counters are reproducible.
for command in (
["shell", "am", "force-stop", "com.artemis.fixture"],
["shell", "am", "start", "-W", "-n", "com.artemis.fixture/.MainActivity"],
):
subprocess.run(
adb_command(["-s", serial, *command]), capture_output=True, check=True, timeout=15
)
screen = await asyncio.to_thread(client.get_screen_data)
image = Image.open(BytesIO(base64.b64decode(screen.base64)))
width, height = image.size
driver = AndroidAdbDriver(serial, AdbClient(), client, width, height)
ctx = SimpleNamespace(
device=SimpleNamespace(device_width=width, device_height=height), data_engine=None
)
actuator = AdbActuator(
ctx, SimpleNamespace(driver=driver, native_request=driver.native_request)
)
async def call(name, *, expect=True, **args):
result = await getattr(actuator, name)(**args)
records.append({"action": name, "args": args, "result": result.model_dump(mode="json")})
check(result.ok is expect, f"{name} expected success={expect}: {result.message}")
return result.metadata
async def read(desc):
return (await call("read_control", selector={"content_desc": desc}))["node"]
async def control(desc, action, **args):
# Focus/selection can asynchronously pan the window for the IME.
# Observe a settled target instead of treating a safe stale-token
# rejection during that animation as successful input or retrying it.
async def settled_editor():
await call(
"wait_until",
selector={"content_desc": desc},
state="stable",
timeout_ms=3000,
stable_ms=500,
poll_interval_ms=100,
)
if desc == "fixture.editor":
await settled_editor()
receipt = await call(
"control_action", selector={"content_desc": desc}, action=action, **args
)
if desc == "fixture.editor":
# Selection handles/toolbars can disappear after a successful
# write. Wait through incomplete window reads; never replay it.
await settled_editor()
return receipt
await read("fixture.title")
original = state()
records.append(
{
"initial_fixture_state": original,
"helper_version": session.version_name,
"helper_code": session.version_code,
}
)
ambiguous = await call(
"read_control", expect=False, selector={"class_name": "android.widget.TextView"}
)
check(ambiguous["code"] == "AMBIGUOUS", "ambiguous selector rejected")
missing = await call(
"wait_until",
selector={"content_desc": "fixture.missing"},
state="absent",
timeout_ms=100,
)
check(missing["success"], "short absent wait works")
timeout = await call(
"wait_until",
expect=False,
selector={"content_desc": "fixture.missing"},
state="visible",
timeout_ms=100,
)
check(timeout["code"] == "TIMEOUT", "missing visible times out")
unsupported = await call(
"control_action",
expect=False,
selector={"content_desc": "fixture.click"},
action="set_progress",
value=50,
)
check(unsupported["execution"] == "not_started", "unsupported node action did not start")
for iteration in range(repeats):
print(
f"fixture iteration {iteration + 1}/{repeats}: native controls/text/wait",
flush=True,
)
value = [73, 21, 88, 49, 65][iteration % 5]
node = await read("fixture.range")
check(
node["range"]["min"] == 0 and node["range"]["max"] == 100, "range endpoints exact"
)
await control(
"fixture.range", "set_progress", value=value, expected_token=node["token"]
)
await call(
"wait_until",
selector={"content_desc": "fixture.range"},
state="value",
expected_value=value,
timeout_ms=1500,
)
check(state()["progress"] == value, "range value persisted in app")
before = state()
await control("fixture.click", "click")
await control("fixture.long_click", "long_click")
after = state()
check(after["click_count"] == before["click_count"] + 1, "native click delivered once")
check(
after["long_click_count"] == before["long_click_count"] + 1,
"native long click delivered once",
)
await control("fixture.editor", "set_text", text="A😀B中文")
bad = await call(
"control_action",
expect=False,
selector={"content_desc": "fixture.editor"},
action="set_selection",
selection_start=2,
selection_end=3,
)
check(
bad["execution"] == "not_started" and state()["text"] == "A😀B中文",
"surrogate split rejected before edit",
)
await control("fixture.editor", "set_selection", selection_start=1, selection_end=3)
selected = await read("fixture.editor")
check(
(selected["selection_start"], selected["selection_end"]) == (1, 3),
"UTF16 emoji selection exact",
)
await control("fixture.editor", "replace_selection", text=" X ")
edited = await read("fixture.editor")
check(
edited["text"] == "A X B中文"
and edited["selection_start"] == edited["selection_end"] == 4,
"replace only selection and place caret",
)
stale = await call(
"control_action",
expect=False,
selector={"content_desc": "fixture.editor"},
action="set_text",
text="wrong",
expected_token=selected["token"],
)
check(
stale["code"] == "STALE" and state()["text"] == "A X B中文",
"stale token cannot edit changed node",
)
await control("fixture.editor", "set_text", text=" 保留空白 😀 ")
check(
(await read("fixture.editor"))["text"] == " 保留空白 😀 ",
"native set text preserves whitespace",
)
editor_node = await read("fixture.editor")
if not editor_node["focused"]:
await control("fixture.editor", "focus")
ime_before = state()["ime_enter_count"]
await control("fixture.editor", "ime_enter")
check(state()["ime_enter_count"] == ime_before + 1, "IME submit delivered once")
ime = subprocess.run(
adb_command(["-s", serial, "shell", "dumpsys", "input_method"]),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
timeout=10,
)
if "mInputShown=true" in ime.stdout:
check(await asyncio.to_thread(client.press_key, "back"), "dismiss visible IME")
deadline = time.monotonic() + 2
while True:
ime = subprocess.run(
adb_command(["-s", serial, "shell", "dumpsys", "input_method"]),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=True,
timeout=10,
)
if "mInputShown=false" in ime.stdout or time.monotonic() >= deadline:
break
await asyncio.sleep(0.05)
check("mInputShown=false" in ime.stdout, "IME no longer covers touch surface")
await call(
"wait_until",
selector={"content_desc": "fixture.touch"},
state="stable",
stable_ms=300,
timeout_ms=1500,
poll_interval_ms=100,
)
before = state()
await control("fixture.delayed", "click")
waiting = await read("fixture.status")
expected_version = (
int(before["status"].rsplit(":", 1)[-1]) + 1
if before["status"].startswith("status:changed:")
else iteration + 1
)
# Each delayed callback increments its own counter once per iteration.
await call(
"wait_until",
selector={"content_desc": "fixture.status"},
state="text",
expected_text=f"status:changed:{expected_version}",
timeout_ms=2500,
poll_interval_ms=100,
)
check(
waiting["text"] in {"status:waiting", f"status:changed:{expected_version}"},
"condition read uses app text",
)
await call(
"wait_until",
selector={"content_desc": "fixture.status"},
state="stable",
stable_ms=150,
timeout_ms=1000,
poll_interval_ms=50,
)
print(f"fixture iteration {iteration + 1}/{repeats}: synchronized touch", flush=True)
await control("fixture.touch_reset", "click")
touch = await read("fixture.touch")
x1, y1, x2, y2 = touch["bounds"]
left, right = max(0, x1) + 20, min(width - 1, x2) - 20
top, bottom = max(0, y1) + 20, min(height - 70, y2) - 20
check(right - left > 200 and bottom - top > 160, "touch fixture has room for gesture")
center = [(left + right) // 2, (top + bottom) // 2]
await call(
"pinch",
center=center,
start_span=80,
end_span=160,
duration_ms=400,
coordinate_space="pixel",
target_description="fixture touch surface",
)
touch_state = state()["touch"]
check(
touch_state["max_pointer_count"] == 2
and touch_state["down_count"] == touch_state["up_count"] == 1,
"pinch has one contact with two pointers",
)
check(
touch_state["cancel_count"] == 0 and 1.85 < touch_state["scale"] < 2.15,
"pinch ratio near two without cancellation",
)
await control("fixture.touch_reset", "click")
await call(
"pinch",
center=center,
start_span=120,
end_span=120,
rotation_degrees=60,
duration_ms=500,
coordinate_space="pixel",
target_description="fixture rotation",
)
touch_state = state()["touch"]
check(
abs(touch_state["rotation_degrees"] - 60) < 3,
"two finger rotation reaches 60 degrees",
)
check(
touch_state["down_count"] == touch_state["up_count"] == 1
and touch_state["cancel_count"] == 0,
"rotation keeps contact across segments",
)
await control("fixture.touch_reset", "click")
cx, cy = center
await call(
"touch_gesture",
frames=[
{"at_ms": 0, "points": [[cx - 60, cy]]},
{"at_ms": 250, "points": [[cx - 60, cy]]},
{"at_ms": 500, "points": [[cx, cy - 30]]},
{"at_ms": 750, "points": [[cx + 60, cy + 30]]},
],
coordinate_space="pixel",
target_description="fixture hold then path",
)
touch_state = state()["touch"]
check(
touch_state["down_count"] == touch_state["up_count"] == 1
and touch_state["move_count"] > 0
and touch_state["cancel_count"] == 0,
"multi segment hold is a single continuous contact",
)
events = touch_state["events"]
down = next(event for event in events if event["action"] == 0)
up = next(event for event in reversed(events) if event["action"] == 1)
first = down["pointers"][0]
moved = next(
event
for event in events
if event["action"] == 2
and any(abs(event["pointers"][0][axis] - first[axis]) > 1 for axis in ("x", "y"))
)
check(
moved["time_ms"] - down["time_ms"] >= 235, "APP timestamps prove initial 250ms hold"
)
check(
up["time_ms"] - down["time_ms"] >= 730,
"APP timestamps prove total 750ms path duration",
)
records.append({"iteration": iteration + 1, "fixture_state": state()})
print("fixture scoped scroll repeated twice", flush=True)
for _ in range(2):
await call(
"scroll_to",
container={"content_desc": "fixture.scroll"},
target={"content_desc": "fixture.bottom"},
max_scrolls=15,
timeout_ms=20000,
settle_ms=200,
)
check((await read("fixture.bottom"))["visible"], "scoped scroll found bottom")
await call(
"scroll_to",
container={"content_desc": "fixture.scroll"},
target={"content_desc": "fixture.title"},
direction="backward",
max_scrolls=15,
timeout_ms=20000,
settle_ms=200,
)
check((await read("fixture.title"))["visible"], "scoped scroll restored top")
await control("fixture.editor", "set_text", text=original["text"])
await control("fixture.range", "set_progress", value=original["progress"])
final_screen = await asyncio.to_thread(client.get_screen_data)
(output / "fixture-final.jpg").write_bytes(base64.b64decode(final_screen.base64))
print(f"PASS {len(checks)} checks / {repeats} repeated fixture iterations", flush=True)
finally:
if client.session is not None:
records.append({"final_fixture_state": state()})
(output / "result.json").write_text(
json.dumps(
{
"checks": checks,
"records": records,
"elapsed_seconds": time.monotonic() - started,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
client.disconnect()
lease.release()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--serial", required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--repeats", type=int, choices=range(1, 11), default=5)
options = parser.parse_args()
asyncio.run(verify(options.serial, options.output, options.repeats))