diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 4f49816..4d1f32e 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -3,6 +3,7 @@ import android.accessibilityservice.AccessibilityService; import android.accessibilityservice.GestureDescription; import android.annotation.SuppressLint; +import android.app.KeyguardManager; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; @@ -15,6 +16,7 @@ import android.graphics.Rect; import android.os.Handler; import android.os.Looper; +import android.os.PowerManager; import android.os.SystemClock; import android.util.DisplayMetrics; import android.util.Log; @@ -68,14 +70,50 @@ public class TouchHelperServiceImpl { private volatile boolean skipAdRunning, skipAdByActivityPosition, skipAdByActivityWidget, skipAdByKeyword; private PackageManager packageManager; + private KeyguardManager keyguardManager; + private PowerManager powerManager; + // set when the lock screen (or a dark screen) covered the current target app; the next window + // of that app is then a warm start that may show a splash ad. Broadcasts such as USER_PRESENT + // are not delivered to third-party apps on every device (OPPO: "oplus skip sp"), so this is + // detected from the accessibility events themselves. + private volatile boolean wakeupPending; // written on the main thread, read on the executor thread private volatile String currentPackageName, currentActivityName; private String packageName; private volatile Set setPackages = Collections.emptySet(); private volatile Set setIMEApps = Collections.emptySet(); private volatile Set setWhiteList = Collections.emptySet(); - // widgets clicked during the current skip-ad process; cleared on the main thread, used on the executor - private final Set clickedWidgets = ConcurrentHashMap.newKeySet(); + // per-process click state. startSkipAdProcess() installs a fresh session; traversals capture + // the session they started under and only ever write to that one, so a click that completes + // after the user re-entered the app cannot leak into the new process. See clickNode(). + private volatile ClickSession session = new ClickSession(); + + private static final class ClickSession { + /** widgets clicked in this process, keyed by clickKey() */ + final Map clickedWidgets = new ConcurrentHashMap<>(); + /** set once a widget rule got its final attempt; widget matching then stops for this process */ + volatile boolean widgetRulesDone; + } + + /** the widget rules still in force for the current process, or null */ + private Set activeWidgetRules() { + return session.widgetRulesDone ? null : setTargetedWidgets; + } + + /** + * A widget is clicked at most {@link #MAX_CLICK_ATTEMPTS} times per skip-ad process. The + * first attempt uses ACTION_CLICK, which cannot hit anything but the widget itself but is + * ignored by some ad SDKs (performAction() returns true although nothing happens). If the + * same widget is still on screen {@link #CLICK_RETRY_MIN_INTERVAL_MS} later, the second + * attempt is a real touch gesture at the widget's centre. + */ + private static final int MAX_CLICK_ATTEMPTS = 2; + private static final long CLICK_RETRY_MIN_INTERVAL_MS = 500; + + private static final class ClickAttempt { + int attempts; + long lastUptime; + } private volatile List keyWordList = Collections.emptyList(); // immutable snapshots of the user rules, replaced wholesale when settings change @@ -176,7 +214,10 @@ public void onServiceConnected() { packageManager = service.getPackageManager(); updatePackage(); - clickedWidgets.clear(); + keyguardManager = (KeyguardManager) service.getSystemService(Context.KEYGUARD_SERVICE); + powerManager = (PowerManager) service.getSystemService(Context.POWER_SERVICE); + + session = new ClickSession(); // install receiver and handler for broadcasting events InstallReceiverAndHandler(); @@ -222,7 +263,11 @@ static Map> snapshotWidgets(Map widgets = setTargetedWidgets; + Set widgets = activeWidgetRules(); if (widgets == null && !skipAdByKeyword) return; if (BuildConfig.DEBUG) { Log.d(TAG, "rescan active window after evicted sub-trees"); @@ -649,6 +713,11 @@ int pendingScanCount() { void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set widgets, boolean byKeyword) { if (root == null) return; final List keywords = byKeyword ? keyWordList : null; + // the process this scan belongs to; all click bookkeeping goes to this object only + final ClickSession session = this.session; + if (session.widgetRulesDone) { + widgets = null; + } final ArrayDeque queue = new ArrayDeque<>(64); queue.add(root); final Rect bounds = new Rect(); @@ -659,19 +728,19 @@ void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set keywords) { + private boolean matchesKeyword(AccessibilityNodeInfo node, List keywords, ClickSession session) { CharSequence description = node.getContentDescription(); CharSequence text = node.getText(); if ((description == null || description.length() == 0) && (text == null || text.length() == 0)) { @@ -739,37 +808,169 @@ private boolean matchesKeyword(AccessibilityNodeInfo node, List keywords if (BuildConfig.DEBUG) { Log.d(TAG, "identify keyword = " + keyword); } - return !clickedWidgets.contains(Utilities.describeAccessibilityNode(node)); + return canClick(clickKey(node), session); } /** * 点击包含关键字的控件 */ - private void clickKeywordNode(AccessibilityNodeInfo node) { - String nodeDesc = Utilities.describeAccessibilityNode(node); + private void clickKeywordNode(AccessibilityNodeInfo node, final List keywords, ClickSession session) { if (BuildConfig.DEBUG) { - Log.d(TAG, nodeDesc); + Log.d(TAG, Utilities.describeAccessibilityNode(node)); } - if (!clickedWidgets.add(nodeDesc)) { - // already clicked this widget in this skip-ad process - return; + Rect bounds = new Rect(); + node.getBoundsInScreen(bounds); + clickNode(node, bounds, false, "正在根据关键字跳过广告...", session, refreshed -> { + CharSequence text = refreshed.getText(); + CharSequence description = refreshed.getContentDescription(); + return SkipAdRules.findKeyword(text == null ? null : text.toString(), + description == null ? null : description.toString(), keywords, SelfPackageName) != null; + }); + } + + /** + * Identity of a widget within one skip-ad process: class, view id and screen bounds. The + * text is left out on purpose so that a countdown label ("跳过 5" → "跳过 4") stays the same + * widget and its click attempts are counted together. + */ + private static String clickKey(AccessibilityNodeInfo node) { + Rect bounds = new Rect(); + node.getBoundsInScreen(bounds); + StringBuilder key = new StringBuilder(96); + key.append(node.getClassName()).append('|').append(node.getViewIdResourceName()).append('|') + .append(bounds.left).append(',').append(bounds.top).append(',') + .append(bounds.right).append(',').append(bounds.bottom); + return key.toString(); + } + + /** whether the widget may be clicked now: not exhausted, and not clicked again too soon */ + private boolean canClick(String key, ClickSession session) { + ClickAttempt attempt = session.clickedWidgets.get(key); + if (attempt == null) return true; + return attempt.attempts < MAX_CLICK_ATTEMPTS + && SystemClock.uptimeMillis() - attempt.lastUptime >= CLICK_RETRY_MIN_INTERVAL_MS; + } + + /** Re-evaluates a rule against a node that was just refreshed from the app. */ + private interface Recheck { + boolean stillMatches(AccessibilityNodeInfo refreshed); + } + + private enum ClickResult { + /** nothing was issued: exhausted, too soon, or the gesture was refused by the checks */ + NONE, + /** a click was issued and the widget may get one more attempt later */ + CLICKED, + /** a click was issued and this was the last attempt for the widget */ + FINAL + } + + /** + * Click a matched widget, escalating from ACTION_CLICK to a touch gesture when the widget + * is still on screen at the next sighting (see {@link #MAX_CLICK_ATTEMPTS}). Only clicks + * that are actually issued count as attempts. + * + * @param gestureOnly skip ACTION_CLICK altogether (the "onlyClick" flag of widget rules) + * @param session the process this click belongs to; attempts are recorded there only + * @param recheck confirms the rule still matches after the node has been refreshed + */ + private ClickResult clickNode(AccessibilityNodeInfo node, Rect bounds, boolean gestureOnly, String toast, + ClickSession session, Recheck recheck) { + String key = clickKey(node); + if (!canClick(key, session)) { + return ClickResult.NONE; } - ShowToastInIntentService("正在根据关键字跳过广告..."); - boolean clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK); - if (BuildConfig.DEBUG) { - Log.d(TAG, "self clicked = " + clicked); + ClickAttempt attempt = session.clickedWidgets.get(key); + int done = attempt == null ? 0 : attempt.attempts; + boolean useGesture = gestureOnly || done > 0; + boolean issued; + if (!useGesture) { + boolean clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK); + if (!clicked) { + // the label itself is often not clickable, its container is + AccessibilityNodeInfo parent = node.getParent(); + if (parent != null) { + clicked = parent.performAction(AccessibilityNodeInfo.ACTION_CLICK); + recycleNode(parent); + } + } + if (BuildConfig.DEBUG) { + Log.d(TAG, "self clicked = " + clicked); + } + // ACTION_CLICK was issued either way; a rejected one is retried as a gesture now + issued = true; + if (!clicked) { + clickByGesture(node, bounds, recheck); + } + } else { + issued = clickByGesture(node, bounds, recheck); + } + if (!issued) { + return ClickResult.NONE; } - if (!clicked) { - Rect rect = new Rect(); - node.getBoundsInScreen(rect); - click(rect.centerX(), rect.centerY(), 0, 20); + // recorded in the session this scan started under: if a new process began while the + // click was in flight, this lands in the old, discarded session and never in the new one + if (attempt == null) { + attempt = new ClickAttempt(); + session.clickedWidgets.put(key, attempt); } + attempt.attempts = done + 1; + attempt.lastUptime = SystemClock.uptimeMillis(); + ShowToastInIntentService(toast); + return attempt.attempts >= MAX_CLICK_ATTEMPTS ? ClickResult.FINAL : ClickResult.CLICKED; + } + + /** + * Touch gesture at the centre of the widget. The node may be a snapshot taken before an + * earlier click closed the ad, so it is refreshed first and the gesture is only dispatched + * when the widget still exists in a window we handle, still matches its rule, and its + * centre is a visible spot on screen. + * + * @return true when a gesture was dispatched + */ + private boolean clickByGesture(AccessibilityNodeInfo node, Rect bounds, Recheck recheck) { + if (!node.refresh()) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "gesture click skipped, widget is gone"); + } + return false; + } + CharSequence pkg = node.getPackageName(); + if (pkg == null || !setPackages.contains(pkg.toString())) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "gesture click skipped, widget now belongs to " + pkg); + } + return false; + } + if (recheck != null && !recheck.stillMatches(node)) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "gesture click skipped, widget no longer matches the rule"); + } + return false; + } + node.getBoundsInScreen(bounds); + DisplayMetrics metrics = service.getResources().getDisplayMetrics(); + int x = bounds.centerX(), y = bounds.centerY(); + boolean onScreen = !bounds.isEmpty() && x >= 0 && y >= 0 + && (metrics.widthPixels <= 0 || x < metrics.widthPixels) + && (metrics.heightPixels <= 0 || y < metrics.heightPixels); + if (!onScreen || !node.isVisibleToUser()) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "gesture click skipped, widget not on screen: " + bounds.toShortString()); + } + return false; + } + boolean dispatched = click(x, y, 0, 20); + if (BuildConfig.DEBUG) { + Log.d(TAG, "gesture clicked at (" + x + ", " + y + ") dispatched = " + dispatched); + } + return dispatched; } /** * 查找并点击由 ActivityWidgetDescription 定义的控件 */ - private boolean skipAdByTargetedWidget(AccessibilityNodeInfo node, Set set, Rect bounds) { + private boolean skipAdByTargetedWidget(AccessibilityNodeInfo node, Set set, Rect bounds, ClickSession session) { node.getBoundsInScreen(bounds); CharSequence cId = node.getViewIdResourceName(); CharSequence cDescribe = node.getContentDescription(); @@ -786,27 +987,26 @@ private boolean skipAdByTargetedWidget(AccessibilityNodeInfo node, Set { + Rect r = new Rect(); + refreshed.getBoundsInScreen(r); + CharSequence id = refreshed.getViewIdResourceName(); + CharSequence desc = refreshed.getContentDescription(); + CharSequence text = refreshed.getText(); + return SkipAdRules.findWidget(r.left, r.top, r.right, r.bottom, + id == null ? null : id.toString(), desc == null ? null : desc.toString(), + text == null ? null : text.toString(), Collections.singleton(rule)) != null; + }); + if (result == ClickResult.NONE) { + // already handled in this process (or not clickable right now), keep looking return false; } - ShowToastInIntentService("正在根据控件跳过广告..."); - if (e.onlyClick) { - click(bounds.centerX(), bounds.centerY(), 0, 20); - } else if (!node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) { - AccessibilityNodeInfo parent = node.getParent(); - boolean parentClicked = false; - if (parent != null) { - parentClicked = parent.performAction(AccessibilityNodeInfo.ACTION_CLICK); - recycleNode(parent); - } - if (!parentClicked) { - click(bounds.centerX(), bounds.centerY(), 0, 20); - } + if (result == ClickResult.FINAL) { + // the widget got its last attempt: stop looking for widget rules in *this* process + // (a newer process that reuses the same rule set keeps them) + session.widgetRulesDone = true; } - // clear setWidgets, stop trying - if (setTargetedWidgets == set) setTargetedWidgets = null; return true; } @@ -866,6 +1066,65 @@ private boolean click(int X, int Y, long start_time, long duration) { return service.dispatchGesture(builder.build(), null, null); } + // apps that show a warm-start ad after the screen was unlocked do so within about a second + private static final long WAKEUP_RESCAN_DELAY_MS = 1000; + + /** + * The screen was turned on or unlocked while an app was in the foreground. Many apps show a + * splash ad on such a warm start, so restart the skip-ad process for that app and scan its + * window right away and again a moment later (the ad renders after the app resumes). + * Nothing happens when the foreground app is not one we handle (launcher, whitelisted app, + * keyguard, IME). + */ + private void resumeSkipAdAfterWakeup() { + final String pkg = currentPackageName; + if (pkg == null || !setPackages.contains(pkg)) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "wakeup while in " + pkg + ", not a target package"); + } + return; + } + if (isScreenLockedOrOff()) { + // SCREEN_ON with the keyguard still up: wait for the app's window to come back + if (BuildConfig.DEBUG) { + Log.d(TAG, "wakeup while in " + pkg + " but still locked, expecting a warm start"); + } + wakeupPending = true; + return; + } + if (BuildConfig.DEBUG) { + Log.d(TAG, "wakeup while in " + pkg + ", start to skip ads now ..."); + } + wakeupPending = false; + startSkipAdProcess(); + // widget rules are resolved here instead of waiting for a window-state event + skipAdByActivityWidget = false; + setTargetedWidgets = mapPackageWidgets.get(pkg); + scheduleTraversal(rootOfActiveTargetWindow(), activeWidgetRules(), skipAdByKeyword, true); + scheduleWakeupRescan(pkg); + } + + /** one more full scan of the app's window a moment after a warm start */ + private void scheduleWakeupRescan(final String pkg) { + receiverHandler.postDelayed(() -> { + if (skipAdRunning && pkg.equals(currentPackageName)) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "warm start rescan of " + pkg); + } + scheduleTraversal(rootOfActiveTargetWindow(), activeWidgetRules(), skipAdByKeyword, true); + } + }, WAKEUP_RESCAN_DELAY_MS); + } + + private boolean isScreenLockedOrOff() { + try { + return (keyguardManager != null && keyguardManager.isKeyguardLocked()) + || (powerManager != null && !powerManager.isInteractive()); + } catch (RuntimeException e) { + return false; + } + } + /** * start the skip-ad process */ @@ -873,13 +1132,13 @@ private void startSkipAdProcess() { if (BuildConfig.DEBUG) { Log.d(TAG, "Start Skip-ad process"); } + session = new ClickSession(); skipAdRunning = true; skipAdByActivityPosition = true; skipAdByActivityWidget = true; skipAdByKeyword = true; setTargetedWidgets = null; clearPendingScans(); - clickedWidgets.clear(); // cancel all methods N seconds later receiverHandler.removeMessages(TouchHelperService.ACTION_STOP_SKIPAD); diff --git a/app/src/main/java/com/zfdang/touchhelper/UserPresentReceiver.java b/app/src/main/java/com/zfdang/touchhelper/UserPresentReceiver.java index 76ebd49..71a008f 100644 --- a/app/src/main/java/com/zfdang/touchhelper/UserPresentReceiver.java +++ b/app/src/main/java/com/zfdang/touchhelper/UserPresentReceiver.java @@ -13,8 +13,10 @@ public class UserPresentReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { // an Intent broadcast, just dispatch message to TouchHelperService String action = intent.getAction(); - if(action.equals(Intent.ACTION_USER_PRESENT)) { - // Sent when the user is present after device wakes up (e.g when the keyguard is gone) + // USER_PRESENT: the keyguard is gone; SCREEN_ON: the screen was turned on (devices + // without a lock screen never send USER_PRESENT). The service checks that the + // foreground app is one we handle before starting anything. + if(Intent.ACTION_USER_PRESENT.equals(action) || Intent.ACTION_SCREEN_ON.equals(action)) { TouchHelperService.dispatchAction(TouchHelperService.ACTION_START_SKIPAD); } } diff --git a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java index 5a74b2b..258549a 100644 --- a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java +++ b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java @@ -7,6 +7,8 @@ import static org.junit.Assert.assertTrue; import static org.robolectric.Shadows.shadowOf; +import android.app.KeyguardManager; +import android.content.Context; import android.content.Intent; import android.graphics.Rect; import android.os.Looper; @@ -19,6 +21,8 @@ import org.junit.runner.RunWith; import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowSystemClock; import org.robolectric.shadows.ShadowAccessibilityNodeInfo; import java.lang.reflect.Field; @@ -38,6 +42,7 @@ * {@link AccessibilityNodeInfo} trees under Robolectric. */ @RunWith(RobolectricTestRunner.class) +@Config(qualifiers = "w1080dp-h2400dp-mdpi") public class TouchHelperServiceImplTest { private static final String AD_PKG = "com.example.ad"; @@ -48,12 +53,29 @@ public class TouchHelperServiceImplTest { public static class RootService extends TouchHelperService { AccessibilityNodeInfo activeRoot; int rootReads; + /** + * when set, the gesture path blocks (inside its screen-size lookup, right before + * dispatching) until released: simulates a slow gesture on the worker thread + */ + volatile CountDownLatch gestureGate, gestureEntered; @Override public AccessibilityNodeInfo getRootInActiveWindow() { rootReads++; return activeRoot == null ? null : AccessibilityNodeInfo.obtain(activeRoot); } + + @Override + public android.content.res.Resources getResources() { + if (Thread.currentThread().getName().startsWith("pool-")) { + CountDownLatch entered = gestureEntered, gate = gestureGate; + if (entered != null) entered.countDown(); + if (gate != null) { + try { gate.await(5, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } + } + } + return super.getResources(); + } } private RootService service; @@ -98,6 +120,12 @@ private static void set(Object target, String name, Object value) throws Excepti f.set(target, value); } + private Object activeWidgetRules() throws Exception { + java.lang.reflect.Method m = TouchHelperServiceImpl.class.getDeclaredMethod("activeWidgetRules"); + m.setAccessible(true); + return m.invoke(impl); + } + private boolean skipAdRunning() throws Exception { return (Boolean) get(impl, "skipAdRunning"); } @@ -148,6 +176,7 @@ private static AccessibilityNodeInfo node(String pkg, String cls, String text, S n.setViewIdResourceName(id); n.setBoundsInScreen(bounds); n.setClickable(true); + n.setVisibleToUser(true); if (clicks != null) { shadowOf(n).setOnPerformActionListener((action, args) -> { if (action == AccessibilityNodeInfo.ACTION_CLICK) clicks.incrementAndGet(); @@ -276,7 +305,7 @@ public void widgetRule_clicksMatchingNodeAndStopsLookingForWidgets() throws Exce awaitExecutor(); assertEquals(1, closeClicks.get()); - assertNull("widget matching should stop after the first hit", get(impl, "setTargetedWidgets")); + assertNotNull("widget rules stay active until the widget got its final attempt", activeWidgetRules()); assertTrue("keyword matching keeps running", skipAdRunning()); } @@ -434,9 +463,8 @@ public void widgetRule_keepsPriorityOverAnEarlierKeywordMatchInTheSameTree() thr assertEquals("the widget rule wins", 1, closeClicks.get()); assertEquals("the keyword node is not clicked when a widget rule matched", 0, keywordClicks.get()); - assertNull(get(impl, "setTargetedWidgets")); - // with the widget rules satisfied, the keyword path takes over for later events + // a later tree without the widget: the keyword path clicks impl.onAccessibilityEvent(contentChanged(AD_PKG, keywordTree(new AtomicInteger(), keywordClicks))); awaitExecutor(); assertEquals(1, keywordClicks.get()); @@ -457,7 +485,7 @@ public void keywordMatch_isClickedWhenNoWidgetRuleMatchesTheTree() throws Except awaitExecutor(); assertEquals(1, skip.get()); assertEquals(0, other.get()); - assertNotNull("no widget matched, so widget matching stays active", get(impl, "setTargetedWidgets")); + assertNotNull("no widget matched, so widget matching stays active", activeWidgetRules()); } private void evictBySpamming(int count) { @@ -557,6 +585,446 @@ public void stateChanged_activityChangeInAnUnhandledPackageDoesNotScan() throws assertEquals(0, service.rootReads); } + // ------------------------------------------------------------------ click escalation + + private int gestures() { + return shadowOf((android.accessibilityservice.AccessibilityService) service).getGesturesDispatched().size(); + } + + /** A "跳过" button that swallows ACTION_CLICK (returns true) but never goes away. */ + private static AccessibilityNodeInfo stubbornSkipButton(String text, AtomicInteger actionClicks) { + return node("android.widget.TextView", text, "com.example.ad:id/skip", new Rect(800, 100, 1000, 180), actionClicks); + } + + @Test + public void click_escalatesToAGestureWhenTheWidgetSurvivesActionClick() throws Exception { + startSkipAdProcess(); + AtomicInteger actionClicks = new AtomicInteger(); + + impl.iterateNodesToSkipAd(stubbornSkipButton("跳过 5", actionClicks), null, true); + assertEquals(1, actionClicks.get()); + assertEquals("first attempt is ACTION_CLICK only", 0, gestures()); + + // the countdown label changed but it is the same widget; too soon for a retry + ShadowSystemClock.advanceBy(Duration.ofMillis(200)); + impl.iterateNodesToSkipAd(stubbornSkipButton("跳过 4", actionClicks), null, true); + assertEquals(1, actionClicks.get()); + assertEquals(0, gestures()); + + // still there half a second later: ACTION_CLICK was a fake success, use a real touch + ShadowSystemClock.advanceBy(Duration.ofMillis(400)); + impl.iterateNodesToSkipAd(stubbornSkipButton("跳过 4", actionClicks), null, true); + assertEquals(1, actionClicks.get()); + assertEquals(1, gestures()); + + // and then give up on this widget for the rest of the process + ShadowSystemClock.advanceBy(Duration.ofSeconds(1)); + impl.iterateNodesToSkipAd(stubbornSkipButton("跳过 3", actionClicks), null, true); + assertEquals(1, actionClicks.get()); + assertEquals(1, gestures()); + } + + @Test + public void click_fallsBackToAGestureImmediatelyWhenActionClickIsRejected() throws Exception { + startSkipAdProcess(); + AccessibilityNodeInfo n = node("android.widget.TextView", "跳过", "com.example.ad:id/skip", new Rect(800, 100, 1000, 180), null); + shadowOf(n).setOnPerformActionListener((action, args) -> false); + impl.iterateNodesToSkipAd(n, null, true); + assertEquals(1, gestures()); + } + + @Test + public void click_triesTheParentBeforeAGesture() throws Exception { + startSkipAdProcess(); + AtomicInteger parentClicks = new AtomicInteger(); + AccessibilityNodeInfo container = node("android.widget.FrameLayout", null, "com.example.ad:id/skip_container", new Rect(790, 90, 1010, 190), parentClicks); + AccessibilityNodeInfo label = node("android.widget.TextView", "跳过", null, new Rect(800, 100, 1000, 180), null); + shadowOf(label).setOnPerformActionListener((action, args) -> false); + shadowOf(container).addChild(label); + impl.iterateNodesToSkipAd(container, null, true); + assertEquals(1, parentClicks.get()); + assertEquals(0, gestures()); + } + + @Test + public void click_neverDispatchesAGestureAtInvalidCoordinates() throws Exception { + startSkipAdProcess(); + // not laid out yet: empty bounds at the origin, and ACTION_CLICK is rejected + AccessibilityNodeInfo n = node("android.widget.TextView", "跳过", "com.example.ad:id/skip", new Rect(0, 0, 0, 0), null); + shadowOf(n).setOnPerformActionListener((action, args) -> false); + impl.iterateNodesToSkipAd(n, null, true); + assertEquals("a tap at (0,0) would hit the status bar", 0, gestures()); + } + + @Test + public void click_widgetRuleWithOnlyClickUsesAGestureRightAway() throws Exception { + PackageWidgetDescription rule = new PackageWidgetDescription(); + rule.packageName = AD_PKG; + rule.idName = "com.example.ad:id/close"; + rule.onlyClick = true; + Map> rules = new HashMap<>(); + rules.put(AD_PKG, new HashSet<>(Collections.singleton(rule))); + set(impl, "mapPackageWidgets", TouchHelperServiceImpl.snapshotWidgets(rules)); + startSkipAdProcess(); + + AtomicInteger actionClicks = new AtomicInteger(); + AccessibilityNodeInfo close = node("android.widget.ImageView", null, "com.example.ad:id/close", new Rect(900, 50, 1000, 150), actionClicks); + impl.iterateNodesToSkipAd(close, (Set) get(impl, "setTargetedWidgets"), true); + assertEquals(0, actionClicks.get()); + assertEquals(1, gestures()); + } + + @Test + public void click_gestureIsNotDispatchedWhenTheWidgetIsGone() throws Exception { + startSkipAdProcess(); + AtomicInteger actionClicks = new AtomicInteger(); + AccessibilityNodeInfo n = stubbornSkipButton("跳过", actionClicks); + impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(n), null, true); + assertEquals(1, actionClicks.get()); + + // the ad closed in the meantime; a queued snapshot of the button is scanned later + ShadowSystemClock.advanceBy(Duration.ofMillis(600)); + AccessibilityNodeInfo stale = AccessibilityNodeInfo.obtain(n); + shadowOf(stale).setRefreshReturnValue(false); + impl.iterateNodesToSkipAd(stale, null, true); + assertEquals("no gesture at the old coordinates", 0, gestures()); + } + + @Test + public void click_gestureIsNotDispatchedIntoAnotherPackagesWindow() throws Exception { + startSkipAdProcess(); + AtomicInteger actionClicks = new AtomicInteger(); + AccessibilityNodeInfo n = stubbornSkipButton("跳过", actionClicks); + impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(n), null, true); + ShadowSystemClock.advanceBy(Duration.ofMillis(600)); + // after refresh the spot belongs to the launcher + n.setPackageName("com.example.launcher"); + impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(n), null, true); + assertEquals(0, gestures()); + } + + @Test + public void click_widgetRuleGetsItsGestureEscalationToo() throws Exception { + PackageWidgetDescription rule = new PackageWidgetDescription(); + rule.packageName = AD_PKG; + rule.idName = "com.example.ad:id/close"; + Map> rules = new HashMap<>(); + rules.put(AD_PKG, new HashSet<>(Collections.singleton(rule))); + set(impl, "mapPackageWidgets", TouchHelperServiceImpl.snapshotWidgets(rules)); + startSkipAdProcess(); + Set targets = (Set) get(impl, "setTargetedWidgets"); + + // ACTION_CLICK is swallowed and the close button (no keyword on it) stays + AtomicInteger actionClicks = new AtomicInteger(); + AccessibilityNodeInfo close = node("android.widget.ImageView", null, "com.example.ad:id/close", new Rect(900, 50, 1000, 150), actionClicks); + impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(close), targets, true); + assertEquals(1, actionClicks.get()); + assertNotNull("rules must stay active so the button can be found again", activeWidgetRules()); + + ShadowSystemClock.advanceBy(Duration.ofMillis(600)); + impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(close), targets, true); + assertEquals(1, actionClicks.get()); + assertEquals("second sighting escalates to a gesture", 1, gestures()); + assertNull("final attempt made, widget rules are done", activeWidgetRules()); + } + + @Test + public void click_finalAttemptOfAnOlderProcessDoesNotDisableTheNewProcessRules() throws Exception { + PackageWidgetDescription rule = new PackageWidgetDescription(); + rule.packageName = AD_PKG; + rule.idName = "com.example.ad:id/close"; + rule.onlyClick = true; + Map> rules = new HashMap<>(); + rules.put(AD_PKG, new HashSet<>(Collections.singleton(rule))); + set(impl, "mapPackageWidgets", TouchHelperServiceImpl.snapshotWidgets(rules)); + startSkipAdProcess(); + + // first attempt in the old process + AccessibilityNodeInfo close = node("android.widget.ImageView", null, "com.example.ad:id/close", new Rect(900, 50, 1000, 150), null); + impl.onAccessibilityEvent(contentChanged(AD_PKG, close)); + awaitExecutor(); + assertEquals(1, gestures()); + ShadowSystemClock.advanceBy(Duration.ofMillis(600)); + + // the final attempt of the old process blocks inside dispatchGesture ... + service.gestureEntered = new CountDownLatch(1); + service.gestureGate = new CountDownLatch(1); + impl.onAccessibilityEvent(contentChanged(AD_PKG, close)); + assertTrue(service.gestureEntered.await(5, TimeUnit.SECONDS)); + + // ... while the user leaves and re-enters the app: new process, same rule set + impl.onAccessibilityEvent(stateChanged("com.example.other", "com.example.other.Main")); + startSkipAdProcess(); + service.gestureGate.countDown(); + awaitExecutor(); + assertEquals("the old final gesture went out", 2, gestures()); + + assertNotNull("the old FINAL must not switch off the new process' widget rules", activeWidgetRules()); + // the button of the new ad is still handled by the rule + impl.onAccessibilityEvent(contentChanged(AD_PKG, close)); + awaitExecutor(); + assertEquals(3, gestures()); + } + + @Test + public void click_refusedGesturesDoNotConsumeAttempts() throws Exception { + PackageWidgetDescription rule = new PackageWidgetDescription(); + rule.packageName = AD_PKG; + rule.idName = "com.example.ad:id/close"; + rule.onlyClick = true; + Map> rules = new HashMap<>(); + rules.put(AD_PKG, new HashSet<>(Collections.singleton(rule))); + set(impl, "mapPackageWidgets", TouchHelperServiceImpl.snapshotWidgets(rules)); + startSkipAdProcess(); + Set targets = (Set) get(impl, "setTargetedWidgets"); + + AccessibilityNodeInfo close = node("android.widget.ImageView", null, "com.example.ad:id/close", new Rect(900, 50, 1000, 150), null); + close.setVisibleToUser(false); + for (int i = 0; i < 3; i++) { + impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(close), targets, true); + ShadowSystemClock.advanceBy(Duration.ofMillis(600)); + } + assertEquals(0, gestures()); + + // the button becomes visible: it must still get its click + close.setVisibleToUser(true); + impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(close), targets, true); + assertEquals(1, gestures()); + } + + @Test + public void click_finishedUnderAnOlderProcessDoesNotCountForTheNewOne() throws Exception { + startSkipAdProcess(); + // ACTION_CLICK blocks (slow app) while the user leaves and re-enters the app + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + AtomicInteger actionClicks = new AtomicInteger(); + AccessibilityNodeInfo slow = node("android.widget.TextView", "跳过", "com.example.ad:id/skip", new Rect(800, 100, 1000, 180), null); + shadowOf(slow).setOnPerformActionListener((action, args) -> { + actionClicks.incrementAndGet(); + entered.countDown(); + try { release.await(5, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } + return true; + }); + impl.onAccessibilityEvent(contentChanged(AD_PKG, slow)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Object oldSession = get(impl, "session"); + + impl.onAccessibilityEvent(stateChanged("com.example.other", "com.example.other.Main")); + startSkipAdProcess(); // new process, fresh attempt table + Object newSession = get(impl, "session"); + release.countDown(); // old click returns now + awaitExecutor(); + assertEquals(1, actionClicks.get()); + // whatever the interleaving, the record lands in the session the scan started under + assertEquals(1, ((Map) get(oldSession, "clickedWidgets")).size()); + assertEquals(0, ((Map) get(newSession, "clickedWidgets")).size()); + + // the same button in the new ad: first attempt again, not blocked and not a gesture + impl.iterateNodesToSkipAd(stubbornSkipButton("跳过", actionClicks), null, true); + assertEquals(2, actionClicks.get()); + assertEquals(0, gestures()); + } + + @Test + public void traversal_startedUnderAnOlderProcessStopsWhenANewOneBegins() throws Exception { + startSkipAdProcess(); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + AtomicInteger laterClicks = new AtomicInteger(); + // first child blocks in ACTION_CLICK, second child would be clicked by a continuing scan + AccessibilityNodeInfo root = node("android.widget.FrameLayout", null, null, new Rect(0, 0, 1080, 1920), null); + AccessibilityNodeInfo slow = node("android.widget.TextView", "跳过", "com.example.ad:id/a", new Rect(0, 0, 100, 50), null); + shadowOf(slow).setOnPerformActionListener((action, args) -> { + entered.countDown(); + try { release.await(5, TimeUnit.SECONDS); } catch (InterruptedException ignored) { } + return false; // rejected, so the scan would go on + }); + shadowOf(slow).setRefreshReturnValue(false); // and no gesture either + AccessibilityNodeInfo later = node("android.widget.TextView", "跳过", "com.example.ad:id/b", new Rect(0, 100, 100, 150), laterClicks); + shadowOf(root).addChild(slow); + shadowOf(root).addChild(later); + impl.onAccessibilityEvent(contentChanged(AD_PKG, root)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + + impl.onAccessibilityEvent(stateChanged("com.example.other", "com.example.other.Main")); + startSkipAdProcess(); + release.countDown(); + awaitExecutor(); + assertEquals("the stale scan must not click into the new process", 0, laterClicks.get()); + } + + @Test + public void click_attemptsAreForgottenByANewProcess() throws Exception { + startSkipAdProcess(); + AtomicInteger actionClicks = new AtomicInteger(); + impl.iterateNodesToSkipAd(stubbornSkipButton("跳过", actionClicks), null, true); + assertEquals(1, actionClicks.get()); + + impl.onAccessibilityEvent(stateChanged("com.example.other", "com.example.other.Main")); + startSkipAdProcess(); + impl.iterateNodesToSkipAd(stubbornSkipButton("跳过", actionClicks), null, true); + assertEquals(2, actionClicks.get()); + } + + // ------------------------------------------------------------------ wake-up / unlock + + private void broadcast(String action) { + new UserPresentReceiver().onReceive(service, new Intent(action)); + shadowOf(Looper.getMainLooper()).idle(); + } + + @Test + public void wakeup_inTargetAppRestartsTheProcessAndScansTheWindowTwice() throws Exception { + // the app was in the foreground, its process finished long ago + startSkipAdProcess(); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(10)); + assertFalse(skipAdRunning()); + + AtomicInteger clicks = new AtomicInteger(); + service.activeRoot = keywordTree(new AtomicInteger(), clicks); + broadcast(Intent.ACTION_USER_PRESENT); + assertTrue(skipAdRunning()); + awaitExecutor(); + assertEquals("scanned right away", 1, service.rootReads); + assertEquals(1, clicks.get()); + + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1100)); + awaitExecutor(); + assertEquals("and once more a second later", 2, service.rootReads); + } + + @Test + public void wakeup_alsoReactsToScreenOn() throws Exception { + startSkipAdProcess(); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(10)); + assertFalse(skipAdRunning()); + broadcast(Intent.ACTION_SCREEN_ON); + assertTrue(skipAdRunning()); + } + + @Test + public void wakeup_inAnUnhandledAppDoesNothing() throws Exception { + // last foreground app is whitelisted / the launcher + impl.onAccessibilityEvent(stateChanged("com.example.launcher", "com.example.launcher.Home")); + service.activeRoot = keywordTree(new AtomicInteger(), new AtomicInteger()); + broadcast(Intent.ACTION_USER_PRESENT); + assertFalse(skipAdRunning()); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2)); + awaitExecutor(); + assertEquals(0, service.rootReads); + } + + @Test + public void wakeup_ignoresTheWindowOfAnotherPackage() throws Exception { + startSkipAdProcess(); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(10)); + // the keyguard is still the active window when the broadcast arrives + AtomicInteger clicks = new AtomicInteger(); + service.activeRoot = node("com.android.systemui", "android.widget.TextView", "跳过", "keyguard:id/x", new Rect(0, 0, 100, 100), clicks); + broadcast(Intent.ACTION_USER_PRESENT); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(2)); + awaitExecutor(); + assertEquals(0, clicks.get()); + } + + // ------------------------------------------------------------------ warm start detected from events + + private void setKeyguardLocked(boolean locked) { + KeyguardManager km = (KeyguardManager) service.getSystemService(Context.KEYGUARD_SERVICE); + shadowOf(km).setKeyguardLocked(locked); + } + + private void finishProcess() throws Exception { + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(10)); + assertFalse(skipAdRunning()); + } + + @Test + public void warmStart_afterTheLockScreenRestartsTheProcessWithoutAnyBroadcast() throws Exception { + startSkipAdProcess(); + finishProcess(); + + // screen off, lock screen shown (a system window that is not an activity), unlock + setKeyguardLocked(true); + impl.onAccessibilityEvent(stateChanged("com.android.systemui", "android.widget.FrameLayout")); + assertFalse(skipAdRunning()); + setKeyguardLocked(false); + AtomicInteger clicks = new AtomicInteger(); + service.activeRoot = keywordTree(new AtomicInteger(), clicks); + impl.onAccessibilityEvent(stateChanged(AD_PKG, AD_ACTIVITY)); + + assertTrue(skipAdRunning()); + awaitExecutor(); + assertEquals("the returning window is scanned", 1, service.rootReads); + assertEquals(1, clicks.get()); + shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(1100)); + awaitExecutor(); + assertEquals("and once more a second later", 2, service.rootReads); + } + + @Test + public void warmStart_waitsUntilTheDeviceIsActuallyUnlocked() throws Exception { + startSkipAdProcess(); + finishProcess(); + setKeyguardLocked(true); + impl.onAccessibilityEvent(stateChanged("com.android.systemui", "android.widget.FrameLayout")); + // the app draws a window while the keyguard is still up (e.g. a notification action) + impl.onAccessibilityEvent(stateChanged(AD_PKG, "android.widget.FrameLayout")); + assertFalse("still locked, nothing to skip yet", skipAdRunning()); + assertTrue("the warm start is still pending", (Boolean) get(impl, "wakeupPending")); + + setKeyguardLocked(false); + impl.onAccessibilityEvent(stateChanged(AD_PKG, AD_ACTIVITY)); + assertTrue(skipAdRunning()); + } + + @Test + public void wakeup_broadcastWhileLockedDefersToTheReturningWindow() throws Exception { + startSkipAdProcess(); + finishProcess(); + setKeyguardLocked(true); + broadcast(Intent.ACTION_SCREEN_ON); + assertFalse("SCREEN_ON with the keyguard up must not start a 4 s process nobody can see", skipAdRunning()); + assertTrue((Boolean) get(impl, "wakeupPending")); + + // no USER_PRESENT on this device; the app's window comes back after the unlock + setKeyguardLocked(false); + impl.onAccessibilityEvent(stateChanged(AD_PKG, AD_ACTIVITY)); + assertTrue(skipAdRunning()); + } + + @Test + public void warmStart_isNotTriggeredByTheNotificationShade() throws Exception { + startSkipAdProcess(); + finishProcess(); + // a system window while the device stays unlocked and interactive (shade, volume panel) + setKeyguardLocked(false); + impl.onAccessibilityEvent(stateChanged("com.android.systemui", "android.widget.FrameLayout")); + impl.onAccessibilityEvent(stateChanged(AD_PKG, AD_ACTIVITY)); + assertFalse(skipAdRunning()); + assertEquals(0, service.rootReads); + } + + @Test + public void warmStart_isNotTriggeredByTheAppsOwnDialogs() throws Exception { + startSkipAdProcess(); + finishProcess(); + setKeyguardLocked(true); // even with a locked keyguard flag, same-package windows are not a cover + impl.onAccessibilityEvent(stateChanged(AD_PKG, "android.widget.FrameLayout")); + impl.onAccessibilityEvent(stateChanged(AD_PKG, AD_ACTIVITY)); + assertFalse(skipAdRunning()); + } + + @Test + public void warmStart_ofAnUnhandledAppDoesNothing() throws Exception { + impl.onAccessibilityEvent(stateChanged("com.example.launcher", "com.example.launcher.Home")); + setKeyguardLocked(true); + impl.onAccessibilityEvent(stateChanged("com.android.systemui", "android.widget.FrameLayout")); + setKeyguardLocked(false); + impl.onAccessibilityEvent(stateChanged("com.example.launcher", "com.example.launcher.Home")); + assertFalse(skipAdRunning()); + assertEquals(0, service.rootReads); + } + @Test public void onUnbind_shutsDownWorkerAndStopsProcess() throws Exception { startSkipAdProcess();