Files

279 lines
23 KiB
Python

"""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())