299 lines
15 KiB
Java
299 lines
15 KiB
Java
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();
|
|
}
|
|
}
|