Files
Artemis-Eapil/tests/native_actions_fixture/OwnershipCases.java
T

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();
}
}