Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,5 @@ lint/tmp/

.vscode/
.omc/
.DS_Store
scripts/output/
50 changes: 45 additions & 5 deletions app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,46 @@ static boolean matchesKeyword(String label, String keyword, String excludedText)
}
String cleanLabel = stripInvisibleChars(label);
String cleanKeyword = stripInvisibleChars(keyword);
if (cleanLabel.length() > cleanKeyword.length() + KEYWORD_MAX_EXTRA_LENGTH) {
return false;

// 1. Direct or substring match (fast path)
if (containsIgnoreCase(cleanLabel, cleanKeyword)) {
if (cleanLabel.length() <= cleanKeyword.length() + KEYWORD_MAX_EXTRA_LENGTH) {
return excludedText == null || !cleanLabel.equalsIgnoreCase(excludedText);
}
}
if (!containsIgnoreCase(cleanLabel, cleanKeyword)) {
return false;

// 2. Normalized separator matching for split text like "跳 过", "跳\n过", "跳·过", "[跳过]"
String compressedLabel = normalizeSeparators(cleanLabel);
String compressedKeyword = normalizeSeparators(cleanKeyword);
if (!compressedKeyword.isEmpty() && containsIgnoreCase(compressedLabel, compressedKeyword)) {
if (compressedLabel.length() <= compressedKeyword.length() + KEYWORD_MAX_EXTRA_LENGTH) {
return excludedText == null || !cleanLabel.equalsIgnoreCase(excludedText);
}
}
return false;
}

/**
* Concatenates text/descriptions from multiple child nodes and checks if together they form
* a skip keyword (e.g. child 0: "跳", child 1: "过" -> "跳过", or "跳过" + "5s").
*/
public static String findCompositeKeyword(List<? extends CharSequence> childTexts,
List<String> keywords,
String excludedText) {
if (childTexts == null || childTexts.size() < 2 || keywords == null || keywords.isEmpty()) {
return null;
}
StringBuilder sb = new StringBuilder(32);
for (int i = 0, n = childTexts.size(); i < n; i++) {
CharSequence seq = childTexts.get(i);
if (seq != null && seq.length() > 0) {
sb.append(seq);
}
}
if (sb.length() == 0) {
return null;
}
return excludedText == null || !cleanLabel.equalsIgnoreCase(excludedText);
return findKeyword(sb.toString(), null, keywords, excludedText);
}

static boolean containsIgnoreCase(String src, String what) {
Expand All @@ -113,6 +146,13 @@ static String stripInvisibleChars(String text) {
return text.replaceAll("[\u200B-\u200D\uFEFF]", "");
}

static String normalizeSeparators(String text) {
if (text == null) {
return "";
}
return text.replaceAll("[\\s\\p{Z}·\\-_|/•\\[\\]()()]+", "");
}

/**
* Finds the first widget rule matching a node. Rules are compared in the same order as
* before: exact screen bounds, then view id, then content description, then text.
Expand Down
137 changes: 128 additions & 9 deletions app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -765,7 +765,8 @@ void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set<PackageWidgetDescripti
break;
}
boolean isCandidate = false;
if (keywords != null && keywordCandidate == null && matchesKeyword(node, keywords, session)) {
if (keywords != null && keywordCandidate == null
&& (matchesKeyword(node, keywords, session) || matchesCompositeKeyword(node, keywords, session))) {
if (widgets == null) {
clickKeywordNode(node, keywords, session);
handled = true;
Expand Down Expand Up @@ -838,6 +839,56 @@ private boolean matchesKeyword(AccessibilityNodeInfo node, List<String> keywords
return canClick(clickKey(node), session);
}

/**
* 检查复合控件:当当前容器本身没有文本时,检查其直接子节点的文本拼接后是否匹配跳过关键字(如子控件1:"跳", 子控件2:"过")。
*/
private boolean matchesCompositeKeyword(AccessibilityNodeInfo node, List<String> keywords, ClickSession session) {
if (node == null || keywords == null || keywords.isEmpty()) {
return false;
}
int childCount = node.getChildCount();
if (childCount < 2 || childCount > 5) {
return false;
}
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
DisplayMetrics metrics = service.getResources().getDisplayMetrics();
int maxAllowedWidth = (int) (metrics.widthPixels * 0.7);
int maxAllowedHeight = (int) (metrics.heightPixels * 0.25);
if (bounds.width() <= 0 || bounds.height() <= 0
|| bounds.width() > maxAllowedWidth || bounds.height() > maxAllowedHeight) {
return false;
}

List<CharSequence> childTexts = new ArrayList<>(childCount);
for (int i = 0; i < childCount; i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
CharSequence text = child.getText();
if (text != null && text.length() > 0) {
childTexts.add(text);
} else {
CharSequence desc = child.getContentDescription();
if (desc != null && desc.length() > 0) {
childTexts.add(desc);
}
}
recycleNode(child);
}
}
if (childTexts.size() < 2) {
return false;
}
String keyword = SkipAdRules.findCompositeKeyword(childTexts, keywords, SelfPackageName);
if (keyword == null) {
return false;
}
if (BuildConfig.DEBUG) {
Log.d(TAG, "identify composite keyword = " + keyword + " in container class=" + node.getClassName());
}
return canClick(clickKey(node), session);
}

/**
* 点击包含关键字的控件
*/
Expand All @@ -850,8 +901,31 @@ private void clickKeywordNode(AccessibilityNodeInfo node, final List<String> key
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;
if (SkipAdRules.findKeyword(text == null ? null : text.toString(),
description == null ? null : description.toString(), keywords, SelfPackageName) != null) {
return true;
}
int childCount = refreshed.getChildCount();
if (childCount >= 2 && childCount <= 5) {
List<CharSequence> childTexts = new ArrayList<>(childCount);
for (int i = 0; i < childCount; i++) {
AccessibilityNodeInfo child = refreshed.getChild(i);
if (child != null) {
CharSequence ct = child.getText();
if (ct != null && ct.length() > 0) {
childTexts.add(ct);
} else {
CharSequence cd = child.getContentDescription();
if (cd != null && cd.length() > 0) {
childTexts.add(cd);
}
}
recycleNode(child);
}
}
return SkipAdRules.findCompositeKeyword(childTexts, keywords, SelfPackageName) != null;
}
return false;
});
}

Expand Down Expand Up @@ -892,6 +966,47 @@ private enum ClickResult {
FINAL
}

/**
* Traverses up the node hierarchy (up to maxLevels) to find a clickable ancestor container.
* Often TextView labels have isClickable=false, while an enclosing FrameLayout / ViewGroup
* is the actual click listener target.
*
* @param node starting node
* @param maxLevels maximum upward steps (e.g. 5)
* @return the clickable ancestor node (caller must recycle), or null if none found
*/
AccessibilityNodeInfo findClickableAncestor(AccessibilityNodeInfo node, int maxLevels) {
if (node == null) return null;
DisplayMetrics metrics = service.getResources().getDisplayMetrics();
int maxAllowedWidth = (int) (metrics.widthPixels * 0.85);
int maxAllowedHeight = (int) (metrics.heightPixels * 0.35);

AccessibilityNodeInfo current = node;
Rect bounds = new Rect();
for (int i = 0; i < maxLevels; i++) {
AccessibilityNodeInfo parent = current.getParent();
if (current != node) {
recycleNode(current);
}
if (parent == null) {
break;
}
if (parent.isClickable()) {
parent.getBoundsInScreen(bounds);
// Ensure the clickable ancestor is a button-like container and not the full screen
if (bounds.width() > 0 && bounds.height() > 0
&& bounds.width() <= maxAllowedWidth && bounds.height() <= maxAllowedHeight) {
return parent;
}
}
current = parent;
}
if (current != null && current != node) {
recycleNode(current);
}
return null;
}

/**
* 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
Expand All @@ -914,15 +1029,19 @@ private ClickResult clickNode(AccessibilityNodeInfo node, Rect bounds, boolean g
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);
// The label itself is often not clickable (or performAction failed):
// bubble up through ancestors to find an enclosing clickable container
AccessibilityNodeInfo ancestor = findClickableAncestor(node, 5);
if (ancestor != null) {
clicked = ancestor.performAction(AccessibilityNodeInfo.ACTION_CLICK);
if (BuildConfig.DEBUG) {
Log.d(TAG, "clickable ancestor (" + ancestor.getClassName() + ") clicked = " + clicked);
}
recycleNode(ancestor);
}
}
if (BuildConfig.DEBUG) {
Log.d(TAG, "self clicked = " + clicked);
Log.d(TAG, "self/ancestor clicked = " + clicked);
}
// ACTION_CLICK was issued either way; a rejected one is retried as a gesture now
issued = true;
Expand Down
24 changes: 24 additions & 0 deletions app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,30 @@ public void findKeyword_skipsBlankKeywordsInsteadOfMatchingEverything() {
assertEquals("跳过", SkipAdRules.findKeyword("跳过", null, withBlank, SELF));
}

@Test
public void findKeyword_matchesSplitKeywordsWithSpacesOrSeparators() {
assertEquals("跳过", SkipAdRules.findKeyword("跳 过", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("跳\n过", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("跳 过", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("跳·过", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("[跳过]", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("(跳过)", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("跳-过", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("跳/过", null, KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findKeyword("跳 过 5s", null, KEYWORDS, SELF));
}

@Test
public void findCompositeKeyword_concatenatesChildTexts() {
assertEquals("跳过", SkipAdRules.findCompositeKeyword(Arrays.asList("跳", "过"), KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findCompositeKeyword(Arrays.asList("跳", "过", "5s"), KEYWORDS, SELF));
assertEquals("跳过", SkipAdRules.findCompositeKeyword(Arrays.asList("5s", "跳过"), KEYWORDS, SELF));
assertEquals("Skip", SkipAdRules.findCompositeKeyword(Arrays.asList("Sk", "ip"), KEYWORDS, SELF));
assertNull(SkipAdRules.findCompositeKeyword(Arrays.asList("跳", "跃"), KEYWORDS, SELF));
assertNull(SkipAdRules.findCompositeKeyword(Collections.singletonList("跳过"), KEYWORDS, SELF));
assertNull(SkipAdRules.findCompositeKeyword(null, KEYWORDS, SELF));
}

// ---------------------------------------------------------------- findWidget

private static PackageWidgetDescription rule(int l, int t, int r, int b, String id, String desc, String text) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1085,4 +1085,72 @@ public void traversal_recyclesEveryNodeItObtains() throws Exception {
assertFalse("traversal leaked AccessibilityNodeInfo instances",
ShadowAccessibilityNodeInfo.areThereUnrecycledNodes(true));
}

@Test
public void contentChanged_bubblesUpToClickableAncestor_whenChildTextNodeIsNotClickable() throws Exception {
startSkipAdProcess();
AtomicInteger containerClicks = new AtomicInteger();
AccessibilityNodeInfo root = node("android.widget.FrameLayout", null, null, new Rect(0, 0, 1080, 1920), null);
// Container button with click listener
AccessibilityNodeInfo container = node("android.widget.LinearLayout", null, "com.example.ad:id/skip_btn",
new Rect(800, 100, 1000, 180), containerClicks);
container.setClickable(true);

// Child TextView inside container has "跳过", but is not clickable
AccessibilityNodeInfo textNode = node("android.widget.TextView", "跳过", null,
new Rect(810, 110, 990, 170), null);
textNode.setClickable(false);
shadowOf(textNode).setOnPerformActionListener((action, args) -> false);

shadowOf(container).addChild(textNode);
shadowOf(root).addChild(container);

impl.onAccessibilityEvent(contentChanged(AD_PKG, root));
awaitExecutor();
assertEquals(1, containerClicks.get());
}

@Test
public void contentChanged_matchesCompositeSplitKeywordInContainer() throws Exception {
startSkipAdProcess();
AtomicInteger containerClicks = new AtomicInteger();
AccessibilityNodeInfo root = node("android.widget.FrameLayout", null, null, new Rect(0, 0, 1080, 1920), null);
// Container button containing split text: child 0 "跳", child 1 "过"
AccessibilityNodeInfo container = node("android.widget.LinearLayout", null, "com.example.ad:id/skip_box",
new Rect(800, 100, 1000, 180), containerClicks);
container.setClickable(true);

AccessibilityNodeInfo part1 = node("android.widget.TextView", "跳", null,
new Rect(800, 100, 890, 180), null);
AccessibilityNodeInfo part2 = node("android.widget.TextView", "过", null,
new Rect(900, 100, 990, 180), null);

shadowOf(container).addChild(part1);
shadowOf(container).addChild(part2);
shadowOf(root).addChild(container);

impl.onAccessibilityEvent(contentChanged(AD_PKG, root));
awaitExecutor();
assertEquals(1, containerClicks.get());
}

@Test
public void traversal_recyclesCompositeNodesProperly() throws Exception {
startSkipAdProcess();
AtomicInteger containerClicks = new AtomicInteger();
AccessibilityNodeInfo root = node("android.widget.FrameLayout", null, null, new Rect(0, 0, 1080, 1920), null);
AccessibilityNodeInfo container = node("android.widget.LinearLayout", null, null,
new Rect(800, 100, 1000, 180), containerClicks);
container.setClickable(true);
shadowOf(container).addChild(node("android.widget.TextView", "跳", null, new Rect(800, 100, 890, 180), null));
shadowOf(container).addChild(node("android.widget.TextView", "过", null, new Rect(900, 100, 990, 180), null));
shadowOf(root).addChild(container);

ShadowAccessibilityNodeInfo.resetObtainedInstances();
impl.iterateNodesToSkipAd(AccessibilityNodeInfo.obtain(root), null, true);
assertEquals(1, containerClicks.get());
assertFalse("composite traversal leaked AccessibilityNodeInfo instances",
ShadowAccessibilityNodeInfo.areThereUnrecycledNodes(true));
}
}

Loading