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 operation; try (MainBlock block = new MainBlock()) { operation = workers.submit(() -> { race.await(); return perform(service, spec(id, 20, 2, 1)); }); Future 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 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 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 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 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 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(); } }