diff --git a/.gitignore b/.gitignore index 063cbe3..02e235e 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,5 @@ lint/tmp/ .vscode/ .omc/ +.DS_Store +scripts/output/ diff --git a/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java b/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java index fbd69a5..92f3a67 100644 --- a/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java +++ b/app/src/main/java/com/zfdang/touchhelper/SkipAdRules.java @@ -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 childTexts, + List 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) { @@ -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. diff --git a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java index 4e0bdca..b0e002d 100644 --- a/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java +++ b/app/src/main/java/com/zfdang/touchhelper/TouchHelperServiceImpl.java @@ -765,7 +765,8 @@ void iterateNodesToSkipAd(AccessibilityNodeInfo root, Set keywords return canClick(clickKey(node), session); } + /** + * 检查复合控件:当当前容器本身没有文本时,检查其直接子节点的文本拼接后是否匹配跳过关键字(如子控件1:"跳", 子控件2:"过")。 + */ + private boolean matchesCompositeKeyword(AccessibilityNodeInfo node, List 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 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); + } + /** * 点击包含关键字的控件 */ @@ -850,8 +901,31 @@ private void clickKeywordNode(AccessibilityNodeInfo node, final List 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 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; }); } @@ -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 @@ -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; diff --git a/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java b/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java index e46fa48..4dfd648 100644 --- a/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java +++ b/app/src/test/java/com/zfdang/touchhelper/SkipAdRulesTest.java @@ -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) { diff --git a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java index d8b2ae0..9b54737 100644 --- a/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java +++ b/app/src/test/java/com/zfdang/touchhelper/TouchHelperServiceImplTest.java @@ -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)); + } } + diff --git a/docs/verified_ad_apps.md b/docs/verified_ad_apps.md new file mode 100644 index 0000000..f451264 --- /dev/null +++ b/docs/verified_ad_apps.md @@ -0,0 +1,49 @@ +# 验证有效的开屏广告测试 APP 清单 (Verified Splash Ad Benchmark Apps) + +本清单记录在真机环境下**确认存在开屏广告**且已被 TouchHelper **100% 成功识别并自动秒跳**的主流应用。在后续开发、重构或升级规则引擎时,可作为回归测试用例。 + +--- + +## 1. 验证应用列表 (Benchmark Apps) + +| 序号 | 应用名称 | 完整包名 (Package Name) | 触发跳过机制 | 响应特征与说明 | +| :---: | :--- | :--- | :---: | :--- | +| 1 | **工银e生活** | `com.icbc.elife` | **父级容器穿透 (Ancestor Bubble Click)** | 内部 `TextView("跳过")` 不可点击,成功向上穿透至父级 `RelativeLayout` 容器执行 `ACTION_CLICK` 秒跳(耗时 <10ms)。 | +| 2 | **建行生活** | `com.ccb.longjiLife` | **直接无障碍点击 (Direct Click)** | 命中关键字 `跳过`,直接对节点执行 `performAction(ACTION_CLICK)` 秒跳。 | +| 3 | **中国电信** | `com.ct.client` | **直接无障碍点击 (Direct Click)** | 开屏展示全屏推广,命中关键字 `跳过`,直接点击成功。 | +| 4 | **哔哩哔哩** | `tv.danmaku.bili` | **直接无障碍点击 (Direct Click)** | 开屏静态/动态广告,命中关键字 `跳过`,无障碍节点直发点击。 | +| 5 | **菜鸟** | `com.cainiao.wireless` | **直接无障碍点击 (Direct Click)** | 阿里系开屏广告,命中关键字 `跳过`,直接点击秒进包裹首页。 | +| 6 | **知乎** | `com.zhihu.android` | **直接无障碍点击 (Direct Click)** | 商业推广开屏,命中关键字 `跳过`,极速跳过进入主页。 | +| 7 | **拼多多** | `com.xunmeng.pinduoduo` | **直接无障碍点击 (Direct Click)** | 大促/活动开屏,命中关键字 `跳过`,无障碍直接点击成功。 | +| 8 | **航旅纵横** | `com.umetrip.android.msky.app` | **直接无障碍点击 (Direct Click)** | 出行信息开屏,命中关键字 `跳过`,直接点击成功。 | +| 9 | **腾讯视频** | `com.tencent.qqlive` | **直接无障碍点击 (Direct Click)** | 影视推荐开屏,命中关键字 `跳过`,直接点击成功。 | +| 10 | **新浪微博** | `com.sina.weibo` | **手势兜底 (Gesture Fallback)** | 微博开屏跳过按钮为复合组件,无障碍点击失败后,TouchHelper 双引擎立即自动在中心坐标触发 `dispatchGesture` 秒跳。 | +| 11 | **携程旅行** | `ctrip.android.view` | **手势兜底 (Gesture Fallback)** | 携程开屏跳过为自定义布局,无障碍点击返回 false 后触发手势兜底点击跳过。 | + +--- + +## 2. 补充观察与分类说明 + +1. **银行主 APP vs 生活类 APP**: + - 国有六大行与主要股份制银行的**主手机银行 APP**(如招行 `cmb.pb`、工行 `com.icbc`、中行 `com.chinamworld.bocmbci`、建行 `com.chinamworld.main`、交行 `com.bankcomm.Bankcomm` 等)冷启动默认**不投放商业开屏广告**,启动后直接进入安全认证/主界面。 + - 银行的**生活服务类 / 信用卡类 APP**(如**工银e生活**、**建行生活**)开屏广告投放频繁,是测试开屏跳过的绝佳真实用例。 + +2. **电商与社交类 APP 开屏频次**: + - 京东、美团、大众点评、网易云音乐等应用在部分版本或一天内首次启动时才展示广告;冷启动若未触发开屏,TouchHelper 会在后台监控并安全放行进入主页面。 + +--- + +## 3. 自动化回归测试工具 + +在仓库中提供了一键运行的自动化测试脚本: + +```bash +# 自动检测已连接设备,依次冷启动上述 11 款测试 APP 并验证跳过效果 +python3 scripts/run_ad_benchmark.py +``` + +测试脚本将自动完成: +1. 唤醒屏幕并回到桌面。 +2. 逐一强制冷启动每个测试应用。 +3. 捕获 TouchHelper 的无障碍日志(`Start Skip-ad process`、`identify keyword`、`self/ancestor clicked`、`gesture clicked`)。 +4. 截取最终落地界面截图并生成详细汇总报告。 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..8e86433 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,40 @@ +# TouchHelper 自动化真机测试工具箱 (Automated Test Toolkit) + +本目录包含针对 TouchHelper 在 Android 真机上的冷启动开屏广告跳过测试脚本套件。 + +--- + +## 脚本清单 + +### 1. `run_ad_benchmark.py` (推荐:已确认开屏广告的基准测试) +- **用途**:对在真机上**确认存在开屏广告**的 11 款主流 APP 进行回归测试。 +- **依赖配置**:`benchmark_apps.json` +- **运行命令**: + ```bash + python3 scripts/run_ad_benchmark.py + # 指定特定设备 + python3 scripts/run_ad_benchmark.py --device + ``` + +### 2. `test_mainstream_apps.py` (常用主流消费级 APP 全面测试) +- **用途**:涵盖 16 款主流应用(B站、微博、京东、知乎、网易云音乐、美团、大众点评、菜鸟、什么值得买、拼多多、携程、航旅纵横、Keep、腾讯视频、网上国网、中国电信)。 +- **运行命令**: + ```bash + python3 scripts/test_mainstream_apps.py + ``` + +### 3. `test_bank_apps.py` (银行与金融类 APP 兼容性测试) +- **用途**:涵盖 12 款银行与金融应用(招商银行、工商银行、工银e生活、中国银行、建设银行、建行生活、交通银行、民生银行、民生信用卡、浦发银行、北京银行、云闪付)。验证金融类 APP 的无障碍兼容性与生活类 APP 的开屏跳过效果。 +- **运行命令**: + ```bash + python3 scripts/test_bank_apps.py + ``` + +--- + +## 运行环境与前置条件 + +1. **已连接 Android 设备并启用 USB 调试**(可通过 `adb devices` 验证)。 +2. **设备已安装 TouchHelper 并开启无障碍服务**。 +3. **Python 3.6+** 环境。 +4. 测试运行期间会自动截取冷启动后的最终界面至 `scripts/output/` 目录供人工核查(已加入 `.gitignore`)。 diff --git a/scripts/benchmark_apps.json b/scripts/benchmark_apps.json new file mode 100644 index 0000000..9f51879 --- /dev/null +++ b/scripts/benchmark_apps.json @@ -0,0 +1,69 @@ +[ + { + "name": "工银e生活", + "pkg": "com.icbc.elife", + "category": "金融生活", + "expected_mode": "Ancestor Bubble Click / Direct Click" + }, + { + "name": "建行生活", + "pkg": "com.ccb.longjiLife", + "category": "金融生活", + "expected_mode": "Direct Click" + }, + { + "name": "中国电信", + "pkg": "com.ct.client", + "category": "通信服务", + "expected_mode": "Direct Click" + }, + { + "name": "哔哩哔哩", + "pkg": "tv.danmaku.bili", + "category": "影音视频", + "expected_mode": "Direct Click" + }, + { + "name": "菜鸟", + "pkg": "com.cainiao.wireless", + "category": "电商快递", + "expected_mode": "Direct Click" + }, + { + "name": "知乎", + "pkg": "com.zhihu.android", + "category": "社交问答", + "expected_mode": "Direct Click" + }, + { + "name": "拼多多", + "pkg": "com.xunmeng.pinduoduo", + "category": "电商购物", + "expected_mode": "Direct Click" + }, + { + "name": "航旅纵横", + "pkg": "com.umetrip.android.msky.app", + "category": "商旅出行", + "expected_mode": "Direct Click" + }, + { + "name": "腾讯视频", + "pkg": "com.tencent.qqlive", + "category": "影音视频", + "expected_mode": "Direct Click" + }, + { + "name": "新浪微博", + "pkg": "com.sina.weibo", + "category": "社交媒体", + "expected_mode": "Gesture Fallback" + }, + { + "name": "携程旅行", + "ctrip.android.view": "ctrip.android.view", + "pkg": "ctrip.android.view", + "category": "商旅出行", + "expected_mode": "Gesture Fallback" + } +] diff --git a/scripts/run_ad_benchmark.py b/scripts/run_ad_benchmark.py new file mode 100755 index 0000000..f3dbc38 --- /dev/null +++ b/scripts/run_ad_benchmark.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +TouchHelper Splash Ad Regression Benchmark Tool +=============================================== +Runs automated cold-start splash ad regression tests against verified apps. + +Usage: + python3 scripts/run_ad_benchmark.py + python3 scripts/run_ad_benchmark.py --device +""" + +import argparse +import json +import os +import subprocess +import sys +import time + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +BENCHMARK_CONFIG = os.path.join(SCRIPT_DIR, "benchmark_apps.json") +OUTPUT_DIR = os.path.join(SCRIPT_DIR, "output") + + +def get_default_device(): + try: + out = subprocess.check_output(["adb", "devices"], text=True).strip() + lines = [line.split()[0] for line in out.splitlines()[1:] if "\tdevice" in line] + if not lines: + print("Error: No ADB devices found. Please connect an Android device.") + sys.exit(1) + return lines[0] + except Exception as e: + print(f"Error executing adb devices: {e}") + sys.exit(1) + + +def run_adb(device, cmd, timeout=15): + full_cmd = ["adb", "-s", device] + cmd + try: + res = subprocess.run(full_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout) + return res.stdout.strip() + except Exception as e: + return f"ERROR: {e}" + + +def check_installed(device, pkg): + out = run_adb(device, ["shell", "pm", "path", pkg]) + return "package:" in out + + +def test_app(device, app_info): + name = app_info["name"] + pkg = app_info["pkg"] + expected = app_info.get("expected_mode", "Direct Click") + + print(f"\n[{name}] ({pkg})...") + + if not check_installed(device, pkg): + print(f" [SKIPPED] {name} is not installed on device {device}.") + return {"name": name, "pkg": pkg, "status": "Not Installed", "clicks": 0, "ancestor": 0} + + # Wake up and home + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]) + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_HOME"]) + time.sleep(0.5) + + # Force stop + run_adb(device, ["shell", "am", "force-stop", pkg]) + time.sleep(1) + + # Clear logcat + run_adb(device, ["logcat", "-c"]) + + # Launch app + out = run_adb(device, ["shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1"]) + if "No activities found" in out or "aborted" in out: + print(f" [ERROR] Launch failed: {out}") + return {"name": name, "pkg": pkg, "status": "Launch Error", "clicks": 0, "ancestor": 0} + + # Wait for splash screen detection and skip + time.sleep(4.5) + + # Pull TouchHelper logs + logs = run_adb(device, ["logcat", "-d", "-s", "TouchHelperServiceImpl:D", "TouchHelperService:D", "SkipAdRules:D"]) + + keywords = [] + ancestor_clicks = 0 + direct_clicks = 0 + gesture_clicks = 0 + + for line in logs.splitlines(): + if "identify keyword =" in line: + keywords.append(line.split("identify keyword =")[-1].strip()) + if "clickable ancestor" in line: + ancestor_clicks += 1 + if "self/ancestor clicked = true" in line: + direct_clicks += 1 + if "gesture clicked" in line or "dispatchGesture" in line: + gesture_clicks += 1 + + # Get current window focus + focus_raw = run_adb(device, ["shell", "dumpsys", "window"]) + focus = "Unknown" + for line in focus_raw.splitlines(): + if "mCurrentFocus" in line: + focus = line.strip() + break + + # Screenshot + os.makedirs(OUTPUT_DIR, exist_ok=True) + shot_path = os.path.join(OUTPUT_DIR, f"{pkg}.png") + run_adb(device, ["shell", "screencap", "-p", f"/sdcard/{pkg}.png"]) + run_adb(device, ["pull", f"/sdcard/{pkg}.png", shot_path]) + run_adb(device, ["shell", "rm", f"/sdcard/{pkg}.png"]) + + # Clean up + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_HOME"]) + run_adb(device, ["shell", "am", "force-stop", pkg]) + + # Evaluate status + if ancestor_clicks > 0: + status = "PASSED (Ancestor Bubble Click)" + elif direct_clicks > 0: + status = "PASSED (Direct Click)" + elif gesture_clicks > 0: + status = "PASSED (Gesture Fallback)" + elif keywords: + status = "PASSED (Keyword Matched)" + else: + status = "MONITORED (No Ad on Launch)" + + print(f" Status: {status} | Keywords: {list(set(keywords))} | Direct: {direct_clicks} | Ancestor: {ancestor_clicks} | Gesture: {gesture_clicks}") + return { + "name": name, + "pkg": pkg, + "status": status, + "keywords": list(set(keywords)), + "direct_clicks": direct_clicks, + "ancestor_clicks": ancestor_clicks, + "gesture_clicks": gesture_clicks, + "focus": focus, + "screenshot": shot_path + } + + +def main(): + parser = argparse.ArgumentParser(description="TouchHelper Splash Ad Regression Benchmark Tool") + parser.add_argument("--device", help="ADB target device serial number", default=None) + args = parser.parse_args() + + device = args.device or get_default_device() + print(f"==================================================") + print(f"TouchHelper Benchmark Target Device: {device}") + print(f"Benchmark Config: {BENCHMARK_CONFIG}") + print(f"==================================================") + + if not os.path.exists(BENCHMARK_CONFIG): + print(f"Config file not found: {BENCHMARK_CONFIG}") + sys.exit(1) + + with open(BENCHMARK_CONFIG, "r", encoding="utf-8") as f: + apps = json.load(f) + + results = [] + for app in apps: + res = test_app(device, app) + results.append(res) + time.sleep(1) + + print(f"\n==================== BENCHMARK REPORT ====================") + print(f"{'App Name':<12} | {'Package':<30} | {'Status'}") + print("-" * 75) + for r in results: + print(f"{r['name']:<12} | {r['pkg']:<30} | {r['status']}") + print(f"==========================================================") + print(f"Screenshots saved to: {OUTPUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_bank_apps.py b/scripts/test_bank_apps.py new file mode 100755 index 0000000..c11f5e5 --- /dev/null +++ b/scripts/test_bank_apps.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +TouchHelper Bank Apps Test Runner +================================= +Tests TouchHelper cold-start behavior across 12 banking and financial apps. + +Usage: + python3 scripts/test_bank_apps.py + python3 scripts/test_bank_apps.py --device +""" + +import argparse +import json +import os +import subprocess +import sys +import time + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +OUTPUT_DIR = os.path.join(SCRIPT_DIR, "output", "banks") + +BANK_APPS = [ + {"name": "招商银行", "pkg": "cmb.pb"}, + {"name": "工商银行", "pkg": "com.icbc"}, + {"name": "工银e生活", "pkg": "com.icbc.elife"}, + {"name": "中国银行", "pkg": "com.chinamworld.bocmbci"}, + {"name": "建设银行", "pkg": "com.chinamworld.main"}, + {"name": "建行生活", "pkg": "com.ccb.longjiLife"}, + {"name": "交通银行", "pkg": "com.bankcomm.Bankcomm"}, + {"name": "民生银行", "pkg": "cn.com.cmbc.newmbank"}, + {"name": "民生信用卡", "pkg": "com.cmbc.cc.mbank"}, + {"name": "浦发银行", "pkg": "cn.com.spdb.mobilebank.per"}, + {"name": "北京银行", "pkg": "com.bankofbeijing.mobilebanking"}, + {"name": "云闪付", "pkg": "com.unionpay"}, +] + + +def get_default_device(): + try: + out = subprocess.check_output(["adb", "devices"], text=True).strip() + lines = [line.split()[0] for line in out.splitlines()[1:] if "\tdevice" in line] + if not lines: + print("Error: No ADB devices found. Please connect an Android device.") + sys.exit(1) + return lines[0] + except Exception as e: + print(f"Error checking adb devices: {e}") + sys.exit(1) + + +def run_adb(device, cmd, timeout=15): + full_cmd = ["adb", "-s", device] + cmd + try: + res = subprocess.run(full_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout) + return res.stdout.strip() + except Exception as e: + return f"ERROR: {e}" + + +def check_installed(device, pkg): + out = run_adb(device, ["shell", "pm", "path", pkg]) + return "package:" in out + + +def test_app(device, app_info): + name = app_info["name"] + pkg = app_info["pkg"] + print(f"\n==========================================") + print(f"Testing Bank App [{name}] ({pkg})...") + + if not check_installed(device, pkg): + print(f" [SKIPPED] {name} is not installed.") + return {"name": name, "pkg": pkg, "status": "Not Installed"} + + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]) + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_HOME"]) + time.sleep(0.5) + + run_adb(device, ["shell", "am", "force-stop", pkg]) + time.sleep(1) + + run_adb(device, ["logcat", "-c"]) + + out = run_adb(device, ["shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1"]) + if "No activities found" in out or "aborted" in out: + print(f" [ERROR] Failed to launch: {out}") + return {"name": name, "pkg": pkg, "status": "Launch Failed"} + + time.sleep(5) + + logs = run_adb(device, ["logcat", "-d", "-s", "TouchHelperServiceImpl:D", "TouchHelperService:D", "SkipAdRules:D"]) + + keywords = [] + ancestor_clicks = 0 + direct_clicks = 0 + gesture_clicks = 0 + + for line in logs.splitlines(): + if "identify keyword =" in line: + keywords.append(line.split("identify keyword =")[-1].strip()) + if "clickable ancestor" in line: + ancestor_clicks += 1 + if "self/ancestor clicked = true" in line: + direct_clicks += 1 + if "gesture clicked" in line or "dispatchGesture" in line: + gesture_clicks += 1 + + focus_raw = run_adb(device, ["shell", "dumpsys", "window"]) + focus = "Unknown" + for line in focus_raw.splitlines(): + if "mCurrentFocus" in line: + focus = line.strip() + break + + os.makedirs(OUTPUT_DIR, exist_ok=True) + shot_path = os.path.join(OUTPUT_DIR, f"{pkg}.png") + run_adb(device, ["shell", "screencap", "-p", f"/sdcard/{pkg}.png"]) + run_adb(device, ["pull", f"/sdcard/{pkg}.png", shot_path]) + run_adb(device, ["shell", "rm", f"/sdcard/{pkg}.png"]) + + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_HOME"]) + run_adb(device, ["shell", "am", "force-stop", pkg]) + + if ancestor_clicks > 0: + status = "Skipped (Ancestor Bubble Click)" + elif direct_clicks > 0: + status = "Skipped (Direct Click)" + elif gesture_clicks > 0: + status = "Skipped (Gesture Fallback)" + elif keywords: + status = "Keyword Matched" + else: + status = "Monitored (No Splash Ad)" + + print(f" Result: {status}") + if keywords: + print(f" Keywords: {list(set(keywords))}") + if direct_clicks: + print(f" Direct clicks: {direct_clicks}") + if ancestor_clicks: + print(f" Ancestor clicks: {ancestor_clicks}") + if gesture_clicks: + print(f" Gesture clicks: {gesture_clicks}") + print(f" Focus: {focus}") + + return { + "name": name, + "pkg": pkg, + "status": status, + "keywords": list(set(keywords)), + "direct_clicks": direct_clicks, + "ancestor_clicks": ancestor_clicks, + "gesture_clicks": gesture_clicks, + "focus": focus, + "screenshot": shot_path + } + + +def main(): + parser = argparse.ArgumentParser(description="TouchHelper Bank Apps Test Runner") + parser.add_argument("--device", help="ADB target device serial number", default=None) + args = parser.parse_args() + + device = args.device or get_default_device() + print(f"==================================================") + print(f"TouchHelper Bank Apps Test on: {device}") + print(f"==================================================") + + results = [] + for app in BANK_APPS: + res = test_app(device, app) + results.append(res) + time.sleep(1) + + print(f"\n==================== BANK TEST SUMMARY ====================") + print(f"{'App Name':<12} | {'Package':<34} | {'Status'}") + print("-" * 80) + for r in results: + print(f"{r['name']:<12} | {r['pkg']:<34} | {r['status']}") + print(f"===========================================================") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_mainstream_apps.py b/scripts/test_mainstream_apps.py new file mode 100755 index 0000000..e2df2d1 --- /dev/null +++ b/scripts/test_mainstream_apps.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +TouchHelper Mainstream Apps Test Runner +====================================== +Tests TouchHelper cold-start behavior across 16 popular mainstream consumer apps +(social, video, e-commerce, lifestyle, utilities, telecom, etc.). + +Usage: + python3 scripts/test_mainstream_apps.py + python3 scripts/test_mainstream_apps.py --device +""" + +import argparse +import json +import os +import subprocess +import sys +import time + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +OUTPUT_DIR = os.path.join(SCRIPT_DIR, "output", "mainstream") + +APPS = [ + {"name": "哔哩哔哩", "pkg": "tv.danmaku.bili"}, + {"name": "微博", "pkg": "com.sina.weibo"}, + {"name": "京东", "pkg": "com.jingdong.app.mall"}, + {"name": "知乎", "pkg": "com.zhihu.android"}, + {"name": "网易云音乐", "pkg": "com.netease.cloudmusic"}, + {"name": "美团", "pkg": "com.sankuai.meituan"}, + {"name": "大众点评", "pkg": "com.dianping.v1"}, + {"name": "菜鸟", "pkg": "com.cainiao.wireless"}, + {"name": "什么值得买", "pkg": "com.smzdm.client.android"}, + {"name": "拼多多", "pkg": "com.xunmeng.pinduoduo"}, + {"name": "携程旅行", "pkg": "ctrip.android.view"}, + {"name": "航旅纵横", "pkg": "com.umetrip.android.msky.app"}, + {"name": "Keep", "pkg": "com.gotokeep.keep"}, + {"name": "腾讯视频", "pkg": "com.tencent.qqlive"}, + {"name": "网上国网", "pkg": "com.sgcc.wsgw.cn"}, + {"name": "中国电信", "pkg": "com.ct.client"}, +] + + +def get_default_device(): + try: + out = subprocess.check_output(["adb", "devices"], text=True).strip() + lines = [line.split()[0] for line in out.splitlines()[1:] if "\tdevice" in line] + if not lines: + print("Error: No ADB devices found. Please connect an Android device.") + sys.exit(1) + return lines[0] + except Exception as e: + print(f"Error checking adb devices: {e}") + sys.exit(1) + + +def run_adb(device, cmd, timeout=15): + full_cmd = ["adb", "-s", device] + cmd + try: + res = subprocess.run(full_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout) + return res.stdout.strip() + except Exception as e: + return f"ERROR: {e}" + + +def check_installed(device, pkg): + out = run_adb(device, ["shell", "pm", "path", pkg]) + return "package:" in out + + +def test_app(device, app_info): + name = app_info["name"] + pkg = app_info["pkg"] + print(f"\n==========================================") + print(f"Testing [{name}] ({pkg})...") + + if not check_installed(device, pkg): + print(f" [SKIPPED] {name} is not installed.") + return {"name": name, "pkg": pkg, "status": "Not Installed"} + + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_WAKEUP"]) + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_HOME"]) + time.sleep(0.5) + + run_adb(device, ["shell", "am", "force-stop", pkg]) + time.sleep(1) + + run_adb(device, ["logcat", "-c"]) + + out = run_adb(device, ["shell", "monkey", "-p", pkg, "-c", "android.intent.category.LAUNCHER", "1"]) + if "No activities found" in out or "aborted" in out: + print(f" [ERROR] Failed to launch: {out}") + return {"name": name, "pkg": pkg, "status": "Launch Failed"} + + time.sleep(4.5) + + logs = run_adb(device, ["logcat", "-d", "-s", "TouchHelperServiceImpl:D", "TouchHelperService:D", "SkipAdRules:D"]) + + keywords = [] + composite = [] + ancestor_clicks = 0 + direct_clicks = 0 + gesture_clicks = 0 + + for line in logs.splitlines(): + if "identify keyword =" in line: + keywords.append(line.split("identify keyword =")[-1].strip()) + if "identify composite keyword =" in line: + composite.append(line.split("identify composite keyword =")[-1].strip()) + if "clickable ancestor" in line: + ancestor_clicks += 1 + if "self/ancestor clicked = true" in line: + direct_clicks += 1 + if "gesture clicked" in line or "dispatchGesture" in line: + gesture_clicks += 1 + + focus_raw = run_adb(device, ["shell", "dumpsys", "window"]) + focus = "Unknown" + for line in focus_raw.splitlines(): + if "mCurrentFocus" in line: + focus = line.strip() + break + + os.makedirs(OUTPUT_DIR, exist_ok=True) + shot_path = os.path.join(OUTPUT_DIR, f"{pkg}.png") + run_adb(device, ["shell", "screencap", "-p", f"/sdcard/{pkg}.png"]) + run_adb(device, ["pull", f"/sdcard/{pkg}.png", shot_path]) + run_adb(device, ["shell", "rm", f"/sdcard/{pkg}.png"]) + + run_adb(device, ["shell", "input", "keyevent", "KEYCODE_HOME"]) + run_adb(device, ["shell", "am", "force-stop", pkg]) + + if ancestor_clicks > 0: + status = "Skipped (Ancestor Bubble Click)" + elif direct_clicks > 0: + status = "Skipped (Direct Click)" + elif gesture_clicks > 0: + status = "Skipped (Gesture Fallback)" + elif keywords or composite: + status = "Keyword Matched" + else: + status = "Monitored (No Splash Ad)" + + print(f" Result: {status}") + if keywords: + print(f" Keywords: {list(set(keywords))}") + if direct_clicks: + print(f" Direct clicks: {direct_clicks}") + if ancestor_clicks: + print(f" Ancestor clicks: {ancestor_clicks}") + if gesture_clicks: + print(f" Gesture clicks: {gesture_clicks}") + print(f" Focus: {focus}") + + return { + "name": name, + "pkg": pkg, + "status": status, + "keywords": list(set(keywords)), + "composite": list(set(composite)), + "direct_clicks": direct_clicks, + "ancestor_clicks": ancestor_clicks, + "gesture_clicks": gesture_clicks, + "focus": focus, + "screenshot": shot_path + } + + +def main(): + parser = argparse.ArgumentParser(description="TouchHelper Mainstream Apps Test Runner") + parser.add_argument("--device", help="ADB target device serial number", default=None) + args = parser.parse_args() + + device = args.device or get_default_device() + print(f"==================================================") + print(f"TouchHelper Mainstream Apps Test on: {device}") + print(f"==================================================") + + results = [] + for app in APPS: + res = test_app(device, app) + results.append(res) + time.sleep(1) + + print(f"\n==================== TEST SUMMARY ====================") + print(f"{'App Name':<12} | {'Package':<32} | {'Status'}") + print("-" * 75) + for r in results: + print(f"{r['name']:<12} | {r['pkg']:<32} | {r['status']}") + print(f"======================================================") + + +if __name__ == "__main__": + main()