feat(android): add native controls and continuous multi-touch
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
@@ -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 助手来绕过问题。
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user