From 065300610b5342904566e7ec757f1403eb52792a Mon Sep 17 00:00:00 2001 From: dantifer Date: Tue, 22 Sep 2026 19:42:57 +0800 Subject: [PATCH 1/6] escalate from ACTION_CLICK to a touch gesture when a skip button survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit performAction(ACTION_CLICK) returning true only means the view handled the event; some ad SDKs ignore accessibility clicks, and a node that is not laid out yet (bounds 0,0,0,0, seen with Zhihu on a real device) reports success without any effect. PR #90 proposed replacing every ACTION_CLICK with a coordinate gesture, which handles those apps but taps the origin for unlaid-out nodes, can hit whatever replaced the ad when it closed a moment earlier, and drops the onlyClick rule flag. This keeps ACTION_CLICK (node, then its parent) as the first attempt, falls back to a gesture immediately when ACTION_CLICK is rejected, and otherwise escalates to a gesture only when the same widget is still on screen at least 500 ms later. A widget is attempted at most twice per skip-ad process. Widgets are identified by class, view id and bounds so that a changing countdown label ("跳过 5" -> "跳过 4") does not reset the attempt count. Gestures are only dispatched at a non-empty, on-screen, visible spot. The onlyClick flag of widget rules still forces a gesture. Adds Robolectric tests for the escalation, the immediate fallback, the parent click, invalid coordinates, onlyClick and attempt reset; 47 unit tests in total. --- .../touchhelper/TouchHelperServiceImpl.java | 139 +++++++++++++----- .../TouchHelperServiceImplTest.java | 106 +++++++++++++ 2 files changed, 212 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 4f49816..70ace74 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -74,8 +74,24 @@ public class TouchHelperServiceImpl { 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(); + // widgets clicked during the current skip-ad process, keyed by clickKey(); cleared on the + // main thread, used on the executor. See clickNode() for the escalation between attempts. + private final Map clickedWidgets = new ConcurrentHashMap<>(); + + /** + * 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 @@ -739,31 +755,103 @@ 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)); } /** * 点击包含关键字的控件 */ private void clickKeywordNode(AccessibilityNodeInfo node) { - String nodeDesc = Utilities.describeAccessibilityNode(node); 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, "正在根据关键字跳过广告..."); + } + + /** + * 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) { + ClickAttempt attempt = clickedWidgets.get(key); + if (attempt == null) return true; + return attempt.attempts < MAX_CLICK_ATTEMPTS + && SystemClock.uptimeMillis() - attempt.lastUptime >= CLICK_RETRY_MIN_INTERVAL_MS; + } + + /** + * 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}). + * + * @param gestureOnly skip ACTION_CLICK altogether (the "onlyClick" flag of widget rules) + * @return true when a click was issued + */ + private boolean clickNode(AccessibilityNodeInfo node, Rect bounds, boolean gestureOnly, String toast) { + String key = clickKey(node); + if (!canClick(key)) { + return false; } - ShowToastInIntentService("正在根据关键字跳过广告..."); - boolean clicked = node.performAction(AccessibilityNodeInfo.ACTION_CLICK); - if (BuildConfig.DEBUG) { - Log.d(TAG, "self clicked = " + clicked); + ClickAttempt attempt = clickedWidgets.get(key); + if (attempt == null) { + attempt = new ClickAttempt(); + clickedWidgets.put(key, attempt); } - if (!clicked) { - Rect rect = new Rect(); - node.getBoundsInScreen(rect); - click(rect.centerX(), rect.centerY(), 0, 20); + attempt.attempts++; + attempt.lastUptime = SystemClock.uptimeMillis(); + ShowToastInIntentService(toast); + + if (!gestureOnly && attempt.attempts == 1) { + 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); + } + if (clicked) { + return true; + } } + return clickByGesture(node, bounds); + } + + /** Touch gesture at the centre of {@code bounds}, only when that point is a visible spot on screen. */ + private boolean clickByGesture(AccessibilityNodeInfo node, Rect 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; } /** @@ -786,25 +874,10 @@ private boolean skipAdByTargetedWidget(AccessibilityNodeInfo node, Set { if (action == AccessibilityNodeInfo.ACTION_CLICK) clicks.incrementAndGet(); @@ -557,6 +561,108 @@ 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_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()); + } + @Test public void onUnbind_shutsDownWorkerAndStopsProcess() throws Exception { startSkipAdProcess(); From 30a0ba835194562bf5c3112ea84647305dcd182e Mon Sep 17 00:00:00 2001 From: dantifer Date: Tue, 22 Sep 2026 19:55:30 +0800 Subject: [PATCH 2/6] handle warm-start ads after the screen was unlocked Apps often show a splash ad when they come back to the foreground after the screen was locked. The existing hook only reacted to USER_PRESENT, restarted the skip-ad process for whatever package was last recorded, even a whitelisted app or the launcher, and then waited passively for events. - detect the warm start from the accessibility events: a system window shown while the keyguard is locked (or the screen is off) marks the current target app as covered, and the next window of that app restarts the skip-ad process. On OPPO devices the system skips USER_PRESENT/SCREEN_ON deliveries to third-party apps ("oplus skip sp"), so this path does not depend on broadcasts at all - the broadcast receiver stays as an additional trigger and now also listens for SCREEN_ON (devices without a lock screen never send USER_PRESENT) - both paths only act when the foreground app is one we handle, and scan its window right away and once more a second later instead of waiting for further events Adds Robolectric tests for the unlock broadcast, SCREEN_ON, unhandled apps, a foreign active window, the event-based detection and its negatives (notification shade, the app's own dialogs); 55 unit tests. --- .../touchhelper/TouchHelperServiceImpl.java | 95 ++++++++++++- .../touchhelper/UserPresentReceiver.java | 6 +- .../TouchHelperServiceImplTest.java | 131 ++++++++++++++++++ 3 files changed, 225 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 70ace74..2b95c92 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,6 +70,13 @@ 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; @@ -192,6 +201,9 @@ public void onServiceConnected() { packageManager = service.getPackageManager(); updatePackage(); + keyguardManager = (KeyguardManager) service.getSystemService(Context.KEYGUARD_SERVICE); + powerManager = (PowerManager) service.getSystemService(Context.POWER_SERVICE); + clickedWidgets.clear(); // install receiver and handler for broadcasting events @@ -238,7 +250,11 @@ static Map> snapshotWidgets(Map { + if (skipAdRunning && pkg.equals(currentPackageName)) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "warm start rescan of " + pkg); + } + scheduleTraversal(rootOfActiveTargetWindow(), setTargetedWidgets, 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 */ 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 0495d7a..de6c7eb 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; @@ -663,6 +665,135 @@ public void click_attemptsAreForgottenByANewProcess() throws Exception { 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_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(); From a70b44c1db8b1c5bc4338483766137a60bd4e4dd Mon Sep 17 00:00:00 2001 From: dantifer Date: Tue, 22 Sep 2026 20:15:57 +0800 Subject: [PATCH 3/6] refresh nodes before gestures and fix retry bookkeeping found in review - a gesture is only dispatched after AccessibilityNodeInfo.refresh() succeeds and the refreshed node still belongs to a target package, still matches its rule, and is a visible spot on screen. A queued snapshot of a button whose ad has already closed is therefore never tapped at its old coordinates - widget rules stay active until the matched widget has had its final attempt, so a button that only matches by id can still be escalated to a gesture at the next sighting - click attempts are counted only when ACTION_CLICK or a gesture was actually issued; a gesture refused by the checks (e.g. the widget is not visible yet) leaves the attempt budget untouched - the pending warm start is kept while the device is still locked: a same-package window during the lock screen, or SCREEN_ON with the keyguard up, no longer starts a process that expires before the user unlocks Adds regression tests for each case; 61 unit tests in total. --- .../touchhelper/TouchHelperServiceImpl.java | 130 ++++++++++++++---- .../TouchHelperServiceImplTest.java | 116 +++++++++++++++- 2 files changed, 217 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 2b95c92..d8d7148 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -386,7 +386,8 @@ public void onAccessibilityEvent(AccessibilityEvent event) { wakeupPending = true; } } else { - if (wakeupPending) { + if (wakeupPending && !isScreenLockedOrOff()) { + // the covered app is back and the device is unlocked: warm start wakeupPending = false; if (setPackages.contains(pkgName)) { if (BuildConfig.DEBUG) { @@ -721,7 +722,7 @@ void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set keywords /** * 点击包含关键字的控件 */ - private void clickKeywordNode(AccessibilityNodeInfo node) { + private void clickKeywordNode(AccessibilityNodeInfo node, final List keywords) { if (BuildConfig.DEBUG) { Log.d(TAG, Utilities.describeAccessibilityNode(node)); } Rect bounds = new Rect(); node.getBoundsInScreen(bounds); - clickNode(node, bounds, false, "正在根据关键字跳过广告..."); + clickNode(node, bounds, false, "正在根据关键字跳过广告...", 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; + }); } /** @@ -827,28 +833,38 @@ private boolean canClick(String key) { && 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}). + * 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) - * @return true when a click was issued + * @param recheck confirms the rule still matches after the node has been refreshed */ - private boolean clickNode(AccessibilityNodeInfo node, Rect bounds, boolean gestureOnly, String toast) { + private ClickResult clickNode(AccessibilityNodeInfo node, Rect bounds, boolean gestureOnly, String toast, Recheck recheck) { String key = clickKey(node); if (!canClick(key)) { - return false; + return ClickResult.NONE; } ClickAttempt attempt = clickedWidgets.get(key); - if (attempt == null) { - attempt = new ClickAttempt(); - clickedWidgets.put(key, attempt); - } - attempt.attempts++; - attempt.lastUptime = SystemClock.uptimeMillis(); - ShowToastInIntentService(toast); - - if (!gestureOnly && attempt.attempts == 1) { + 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 @@ -861,15 +877,56 @@ private boolean clickNode(AccessibilityNodeInfo node, Rect bounds, boolean gestu if (BuildConfig.DEBUG) { Log.d(TAG, "self clicked = " + clicked); } - if (clicked) { - return true; + // 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); } - return clickByGesture(node, bounds); + if (!issued) { + return ClickResult.NONE; + } + if (attempt == null) { + attempt = new ClickAttempt(); + 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 {@code bounds}, only when that point is a visible spot on screen. */ - private boolean clickByGesture(AccessibilityNodeInfo node, Rect bounds) { + /** + * 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 @@ -908,12 +965,25 @@ 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; } - // clear setWidgets, stop trying - if (setTargetedWidgets == set) setTargetedWidgets = null; + if (result == ClickResult.FINAL && setTargetedWidgets == set) { + // the widget got its last attempt, stop looking for widget rules + setTargetedWidgets = null; + } return true; } @@ -991,6 +1061,14 @@ private void resumeSkipAdAfterWakeup() { } 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 ..."); } diff --git a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java index de6c7eb..8d9ea70 100644 --- a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java +++ b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java @@ -282,7 +282,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", get(impl, "setTargetedWidgets")); assertTrue("keyword matching keeps running", skipAdRunning()); } @@ -440,9 +440,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()); @@ -652,6 +651,86 @@ public void click_widgetRuleWithOnlyClickUsesAGestureRightAway() throws Exceptio 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", get(impl, "setTargetedWidgets")); + + 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", get(impl, "setTargetedWidgets")); + } + + @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_attemptsAreForgottenByANewProcess() throws Exception { startSkipAdProcess(); @@ -761,6 +840,37 @@ public void warmStart_afterTheLockScreenRestartsTheProcessWithoutAnyBroadcast() 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(); From b2cead54d56545dbbbaa8e3093fcbcd1acf6f1bb Mon Sep 17 00:00:00 2001 From: dantifer Date: Tue, 22 Sep 2026 20:39:29 +0800 Subject: [PATCH 4/6] keep in-flight clicks and scans from touching a newer skip-ad process startSkipAdProcess() clears the per-process click attempts. A click whose performAction() was still blocking on the worker when the user left and re-entered the app then recorded itself into the fresh table, so the same button in the new ad was either held back by the retry interval or escalated straight to a gesture. Every startSkipAdProcess() now bumps a generation counter. A traversal stops as soon as the generation changes, and a click only records its attempt when it completes under the generation it started in. Adds two controlled-concurrency tests; 63 unit tests in total. --- .../touchhelper/TouchHelperServiceImpl.java | 15 ++++- .../TouchHelperServiceImplTest.java | 55 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index d8d7148..58cff2c 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -96,6 +96,9 @@ public class TouchHelperServiceImpl { */ private static final int MAX_CLICK_ATTEMPTS = 2; private static final long CLICK_RETRY_MIN_INTERVAL_MS = 500; + // incremented by every startSkipAdProcess(); a traversal or click that started under an + // older generation must not touch the state of the new process + private volatile int skipAdGeneration; private static final class ClickAttempt { int attempts; @@ -700,6 +703,7 @@ int pendingScanCount() { void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set widgets, boolean byKeyword) { if (root == null) return; final List keywords = byKeyword ? keyWordList : null; + final int generation = skipAdGeneration; final ArrayDeque queue = new ArrayDeque<>(64); queue.add(root); final Rect bounds = new Rect(); @@ -710,7 +714,7 @@ void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set 0; boolean issued; if (!useGesture) { @@ -888,6 +893,11 @@ private ClickResult clickNode(AccessibilityNodeInfo node, Rect bounds, boolean g if (!issued) { return ClickResult.NONE; } + if (skipAdGeneration != generation) { + // a new skip-ad process started while the click was in flight (the user left and + // re-entered the app); its fresh attempt table must not inherit this record + return ClickResult.NONE; + } if (attempt == null) { attempt = new ClickAttempt(); clickedWidgets.put(key, attempt); @@ -1109,6 +1119,7 @@ private void startSkipAdProcess() { if (BuildConfig.DEBUG) { Log.d(TAG, "Start Skip-ad process"); } + skipAdGeneration++; skipAdRunning = true; skipAdByActivityPosition = true; skipAdByActivityWidget = true; diff --git a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java index 8d9ea70..372ac73 100644 --- a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java +++ b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java @@ -731,6 +731,61 @@ public void click_refusedGesturesDoNotConsumeAttempts() throws Exception { 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)); + + impl.onAccessibilityEvent(stateChanged("com.example.other", "com.example.other.Main")); + startSkipAdProcess(); // new process, fresh attempt table + release.countDown(); // old click returns now + awaitExecutor(); + assertEquals(1, actionClicks.get()); + + // 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(); From 2b467ed1751b5fa0ac00ed5f28f63c9835716edf Mon Sep 17 00:00:00 2001 From: dantifer Date: Tue, 22 Sep 2026 20:47:44 +0800 Subject: [PATCH 5/6] give every skip-ad process its own click session The generation check before recording a click attempt was still a check-then-act: the worker could pass the check, get descheduled, and write into the table that startSkipAdProcess() had cleared in between. Click state now lives in a ClickSession object. startSkipAdProcess() installs a fresh session; a traversal captures the session it started under and passes it to every click, so an attempt is recorded in that session no matter when the click completes. A click that finishes after the user re-entered the app lands in the discarded old session and can never hold back or escalate the first click of the new ad. The stale traversal itself still stops as soon as the session changes. Test asserts the record ends up in the old session and the new one stays empty; 63 unit tests in total. --- .../touchhelper/TouchHelperServiceImpl.java | 69 ++++++++++--------- .../TouchHelperServiceImplTest.java | 5 ++ 2 files changed, 40 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 58cff2c..4821912 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -83,9 +83,15 @@ public class TouchHelperServiceImpl { 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, keyed by clickKey(); cleared on the - // main thread, used on the executor. See clickNode() for the escalation between attempts. - private final Map clickedWidgets = new ConcurrentHashMap<>(); + // 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<>(); + } /** * A widget is clicked at most {@link #MAX_CLICK_ATTEMPTS} times per skip-ad process. The @@ -96,9 +102,6 @@ public class TouchHelperServiceImpl { */ private static final int MAX_CLICK_ATTEMPTS = 2; private static final long CLICK_RETRY_MIN_INTERVAL_MS = 500; - // incremented by every startSkipAdProcess(); a traversal or click that started under an - // older generation must not touch the state of the new process - private volatile int skipAdGeneration; private static final class ClickAttempt { int attempts; @@ -207,7 +210,7 @@ public void onServiceConnected() { keyguardManager = (KeyguardManager) service.getSystemService(Context.KEYGUARD_SERVICE); powerManager = (PowerManager) service.getSystemService(Context.POWER_SERVICE); - clickedWidgets.clear(); + session = new ClickSession(); // install receiver and handler for broadcasting events InstallReceiverAndHandler(); @@ -703,7 +706,8 @@ int pendingScanCount() { void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set widgets, boolean byKeyword) { if (root == null) return; final List keywords = byKeyword ? keyWordList : null; - final int generation = skipAdGeneration; + // the process this scan belongs to; all click bookkeeping goes to this object only + final ClickSession session = this.session; final ArrayDeque queue = new ArrayDeque<>(64); queue.add(root); final Rect bounds = new Rect(); @@ -714,19 +718,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)) { @@ -794,19 +798,19 @@ private boolean matchesKeyword(AccessibilityNodeInfo node, List keywords if (BuildConfig.DEBUG) { Log.d(TAG, "identify keyword = " + keyword); } - return canClick(clickKey(node)); + return canClick(clickKey(node), session); } /** * 点击包含关键字的控件 */ - private void clickKeywordNode(AccessibilityNodeInfo node, final List keywords) { + private void clickKeywordNode(AccessibilityNodeInfo node, final List keywords, ClickSession session) { if (BuildConfig.DEBUG) { Log.d(TAG, Utilities.describeAccessibilityNode(node)); } Rect bounds = new Rect(); node.getBoundsInScreen(bounds); - clickNode(node, bounds, false, "正在根据关键字跳过广告...", refreshed -> { + clickNode(node, bounds, false, "正在根据关键字跳过广告...", session, refreshed -> { CharSequence text = refreshed.getText(); CharSequence description = refreshed.getContentDescription(); return SkipAdRules.findKeyword(text == null ? null : text.toString(), @@ -830,8 +834,8 @@ private static String clickKey(AccessibilityNodeInfo node) { } /** whether the widget may be clicked now: not exhausted, and not clicked again too soon */ - private boolean canClick(String key) { - ClickAttempt attempt = clickedWidgets.get(key); + 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; @@ -857,16 +861,17 @@ private enum ClickResult { * 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, Recheck recheck) { + private ClickResult clickNode(AccessibilityNodeInfo node, Rect bounds, boolean gestureOnly, String toast, + ClickSession session, Recheck recheck) { String key = clickKey(node); - if (!canClick(key)) { + if (!canClick(key, session)) { return ClickResult.NONE; } - ClickAttempt attempt = clickedWidgets.get(key); + ClickAttempt attempt = session.clickedWidgets.get(key); int done = attempt == null ? 0 : attempt.attempts; - final int generation = skipAdGeneration; boolean useGesture = gestureOnly || done > 0; boolean issued; if (!useGesture) { @@ -893,14 +898,11 @@ private ClickResult clickNode(AccessibilityNodeInfo node, Rect bounds, boolean g if (!issued) { return ClickResult.NONE; } - if (skipAdGeneration != generation) { - // a new skip-ad process started while the click was in flight (the user left and - // re-entered the app); its fresh attempt table must not inherit this record - return ClickResult.NONE; - } + // 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(); - clickedWidgets.put(key, attempt); + session.clickedWidgets.put(key, attempt); } attempt.attempts = done + 1; attempt.lastUptime = SystemClock.uptimeMillis(); @@ -958,7 +960,7 @@ private boolean clickByGesture(AccessibilityNodeInfo node, Rect bounds, Recheck /** * 查找并点击由 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(); @@ -976,7 +978,7 @@ private boolean skipAdByTargetedWidget(AccessibilityNodeInfo node, Set { + ClickResult result = clickNode(node, bounds, e.onlyClick, "正在根据控件跳过广告...", session, refreshed -> { Rect r = new Rect(); refreshed.getBoundsInScreen(r); CharSequence id = refreshed.getViewIdResourceName(); @@ -1119,14 +1121,13 @@ private void startSkipAdProcess() { if (BuildConfig.DEBUG) { Log.d(TAG, "Start Skip-ad process"); } - skipAdGeneration++; + 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/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java index 372ac73..6828b83 100644 --- a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java +++ b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java @@ -746,12 +746,17 @@ public void click_finishedUnderAnOlderProcessDoesNotCountForTheNewOne() throws E }); 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); From 1f2021870f1685e48a1423f5fa9895b93a11da5c Mon Sep 17 00:00:00 2001 From: dantifer Date: Tue, 22 Sep 2026 21:12:15 +0800 Subject: [PATCH 6/6] keep the widget-rules-done flag per skip-ad process The final attempt of a widget rule cleared the shared setTargetedWidgets field. When that attempt completed after the user had re-entered the app, it switched off the widget rules of the new process, which reuses the same rule set. The flag now lives in the ClickSession the scan started under and only that session's rules are considered done; scheduling reads the rules through activeWidgetRules(), which hides them once the current session is finished. Test blocks the old process inside its final gesture, starts a new process and checks the rules stay active; 64 unit tests in total. --- .../touchhelper/TouchHelperServiceImpl.java | 31 ++++++--- .../TouchHelperServiceImplTest.java | 69 +++++++++++++++++-- 2 files changed, 86 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 4821912..4d1f32e 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -91,6 +91,13 @@ public class TouchHelperServiceImpl { 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; } /** @@ -470,22 +477,22 @@ public void run() { // third: skip ads by keywords. Both widget and keyword matching share one // traversal of the window; a window state change always gets a full scan. - if (setTargetedWidgets != null || skipAdByKeyword) { + if (activeWidgetRules() != null || skipAdByKeyword) { if (BuildConfig.DEBUG) { Log.d(TAG, "method by keywords in STATE_CHANGED"); } - scheduleTraversal(rootOfActiveTargetWindow(), setTargetedWidgets, skipAdByKeyword, true); + scheduleTraversal(rootOfActiveTargetWindow(), activeWidgetRules(), skipAdByKeyword, true); } break; case AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED: if(!setPackages.contains(tempPkgName.toString())) { break; } - if (setTargetedWidgets != null || skipAdByKeyword) { + if (activeWidgetRules() != null || skipAdByKeyword) { if (BuildConfig.DEBUG) { Log.d(TAG, "method by keywords in CONTENT_CHANGED"); } - scheduleTraversal(event.getSource(), setTargetedWidgets, skipAdByKeyword, false); + scheduleTraversal(event.getSource(), activeWidgetRules(), skipAdByKeyword, false); } break; } @@ -662,7 +669,7 @@ private void rescanActiveWindow() { rescanScheduled = false; } if (!skipAdRunning) return; - Set widgets = setTargetedWidgets; + Set widgets = activeWidgetRules(); if (widgets == null && !skipAdByKeyword) return; if (BuildConfig.DEBUG) { Log.d(TAG, "rescan active window after evicted sub-trees"); @@ -708,6 +715,9 @@ void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set 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(); @@ -992,9 +1002,10 @@ private boolean skipAdByTargetedWidget(AccessibilityNodeInfo node, Set> 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