Commit c5380e1d2d3c7133975bcc14a442f9d206676905

Authored by yibo
1 parent edfe7ab4

feat: A11y 支持群聊引用回复 + @ 群成员

新增功能:
- HermesAccessibilityService 支持广播 extras quoteText / atSender
- quoteText: 在聊天页找文本含此 substring 的 bkl 气泡 → 长按 → 点「引用」
  菜单项 → 输入回复文本 → 点发送
- atSender: 在输入框先打「@」触发选人面板 → 找匹配成员点击 → 追加回复

实测路径完整跑通(msgId 1409 验证):
1. 广播触发 A11y service
2. 进入聊天页("already in correct chat" 或经过搜索)
3. findBubbleByPartialText 在 bkl 列表里倒序匹配
4. dispatchGesture 派发 800ms 合成长按手势(ACTION_LONG_CLICK 在 WeChat
   GestureDetector 实现上不触发菜单,必须用 gesture 派发)
5. 等 1.2s 后用 findClickableByTextAllWindows 搜「引用」 — 关键:菜单在
   popup window 不在 active window,必须遍历 getWindows() 才能找到
6. 点击引用 → 引用 chip 注入到输入框 → 写文本 → 点发送
7. DB 写入的 message 是 type=57 (微信内部引用回复消息类型)

关键依赖(Android AccessibilityService):
- accessibility_service_config.xml 必须设 canPerformGestures="true"
  否则 dispatchGesture 不回调 onCompleted

群聊路径:
- auto-reply.js handleMessage 在 replyType=group-at/group-quote 时自动
  从触发消息 content 解析 bubbleText 当 quoteText,senderName 当 atSender
- android-wechat-sender.js sendTextMessageBroadcast 接受 options.quoteText
  / options.atSender,拼到 am broadcast --es 参数里

测试覆盖(在 Pixel 实测):
- ✅ 私聊回归:plain text 路径不影响(msgId 1408)
- ✅ 引用回复:私聊场景已验证(msgId 1409 type=57)
- ⏸ 群聊 + @ 群成员:代码已写完,需要真实群消息触发才能测

APK 已重编 + 用 debug.keystore 签 + adb install -r 升级(A11y 权限保留)。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
server/services/wechat/android-wechat-sender.js
... ... @@ -101,8 +101,13 @@ async function sendTextMessage(toUser, content) {
101 101 * @param {string} content - 消息内容
102 102 * @param {object} [options]
103 103 * @param {string} [options.toUserDisplayName] - **必传**:A11y 在搜索框中输入的名字
104   - * 通常是 conRemark || nickname,例如 `火星人`。如果省略会 fallback 到 toUser,
105   - * 但绝大多数情况下用 wxid 是搜不到的。
  104 + * 通常是 conRemark || nickname,例如 `火星人` / `宜创AI全员群`。
  105 + * 如果省略会 fallback 到 toUser,但绝大多数情况下用 wxid 是搜不到的。
  106 + * @param {string} [options.quoteText] - 可选:要引用的消息文本片段(substring)。
  107 + * A11y 进入聊天后会在 bkl 气泡里按 substring 匹配,找到后长按 → 点"引用",
  108 + * 然后输入 content + 点发送。找不到时降级为不引用。
  109 + * @param {string} [options.atSender] - 可选:群里要 @ 的成员显示名(如 `小火子`)。
  110 + * A11y 会在输入框先打 "@" 触发选人面板,匹配 atSender 点击,再追加 content。
106 111 * @returns {Promise<{ok: boolean, error?: string}>}
107 112 */
108 113 async function sendTextMessageBroadcast(toUser, content, options = {}) {
... ... @@ -114,11 +119,20 @@ async function sendTextMessageBroadcast(toUser, content, options = {}) {
114 119 const safeDisplayName = escapeShellSingleQuote(displayName);
115 120 const safeContent = escapeShellSingleQuote(content);
116 121  
117   - const cmd = `am broadcast -a ${ACTION_SEND} `
  122 + let cmd = `am broadcast -a ${ACTION_SEND} `
118 123 + `--es toUser '${safeToUser}' `
119 124 + `--es toUserDisplayName '${safeDisplayName}' `
120 125 + `--es content '${safeContent}'`;
121 126  
  127 + if (options.quoteText) {
  128 + // 截到 40 字符,避免 UI 渲染省略号导致 substring match 失败
  129 + const qt = String(options.quoteText).substring(0, 40);
  130 + cmd += ` --es quoteText '${escapeShellSingleQuote(qt)}'`;
  131 + }
  132 + if (options.atSender) {
  133 + cmd += ` --es atSender '${escapeShellSingleQuote(options.atSender)}'`;
  134 + }
  135 +
122 136 try {
123 137 const out = await shell(cmd, 10000);
124 138 // am broadcast 成功输出通常包含 "Broadcast completed: result=0"
... ...
server/services/wechat/auto-reply.js
... ... @@ -552,8 +552,22 @@ class AutoReplyBot {
552 552 replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...';
553 553 }
554 554  
  555 + // 群里被 @ 时,给 broadcast 带上 quoteText(引用触发消息)+ atSender(回复里 @ 对方)
  556 + const sendOpts = { toUserDisplayName: displayName };
  557 + if (replyType === 'group-at' || replyType === 'group-quote') {
  558 + // 群消息 content 的格式是 "senderWxid:\n实际文本",提取实际文本作为 quote substring
  559 + let bubbleText = content;
  560 + const colonIdx = bubbleText.indexOf(':\n');
  561 + if (colonIdx > 0) bubbleText = bubbleText.substring(colonIdx + 2);
  562 + // 取前若干字符作为 substring 匹配;含 XML/特殊字符的部分(如 refermsg)跳过
  563 + const trimmed = bubbleText.replace(/<[^>]+>/g, '').trim();
  564 + if (trimmed) sendOpts.quoteText = trimmed.substring(0, 30);
  565 + if (senderName) sendOpts.atSender = senderName;
  566 + log('[GROUP] quote=' + (sendOpts.quoteText || '-') + ' atSender=' + (sendOpts.atSender || '-'));
  567 + }
  568 +
555 569 try {
556   - const result = await sendTextMessageBroadcast(talker, replyText, { toUserDisplayName: displayName });
  570 + const result = await sendTextMessageBroadcast(talker, replyText, sendOpts);
557 571 if (result.ok) {
558 572 log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60));
559 573 this.state.markReplied(msgId, talker);
... ...
wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java
1 1 package com.hermes.wechat.xposed.accessibility;
2 2  
3 3 import android.accessibilityservice.AccessibilityService;
  4 +import android.accessibilityservice.AccessibilityService.GestureResultCallback;
  5 +import android.accessibilityservice.GestureDescription;
4 6 import android.content.BroadcastReceiver;
5 7 import android.content.Context;
6 8 import android.content.Intent;
7 9 import android.content.IntentFilter;
  10 +import android.graphics.Path;
  11 +import android.graphics.Rect;
8 12 import android.os.Bundle;
9 13 import android.os.Handler;
10 14 import android.os.Looper;
... ... @@ -36,12 +40,18 @@ public class HermesAccessibilityService extends AccessibilityService {
36 40 private static final String ID_CHAT_EDIT = "com.tencent.mm:id/bkk"; // 聊天输入框容器 (FrameLayout)
37 41 private static final String ID_CHAT_SEND = "com.tencent.mm:id/bql"; // 发送按钮 (Button, text="发送")
38 42 private static final String ID_CHAT_TITLE = "com.tencent.mm:id/obn"; // 聊天界面标题栏 (TextView)
  43 + private static final String ID_BUBBLE_TEXT = "com.tencent.mm:id/bkl"; // 消息气泡 TextView (long-clickable=true)
  44 +
  45 + // 长按气泡后弹出的菜单项文案(WeChat 8.0.72 简中)
  46 + private static final String MENU_QUOTE_TEXT = "引用";
39 47  
40 48 private final Handler mainHandler = new Handler(Looper.getMainLooper());
41 49  
42   - private String pendingToUser = null; // wxid / username (for verification)
  50 + private String pendingToUser = null; // wxid / username (for verification)
43 51 private String pendingToUserDisplayName = null; // display name for search
44 52 private String pendingContent = null;
  53 + private String pendingQuoteText = null; // 要引用的消息片段(substring 匹配 bkl text),null=不引用
  54 + private String pendingAtSender = null; // 要 @ 的群成员显示名,null=不 @
45 55  
46 56 @Override
47 57 protected void onServiceConnected() {
... ... @@ -76,6 +86,8 @@ public class HermesAccessibilityService extends AccessibilityService {
76 86 String toUser = intent.getStringExtra("toUser");
77 87 String toUserDisplayName = intent.getStringExtra("toUserDisplayName");
78 88 String content = intent.getStringExtra("content");
  89 + String quoteText = intent.getStringExtra("quoteText"); // 可选:要引用的消息文本片段
  90 + String atSender = intent.getStringExtra("atSender"); // 可选:群里要 @ 的成员显示名
79 91 if (toUser == null || content == null) {
80 92 Log.w(TAG, "Broadcast missing toUser or content");
81 93 return;
... ... @@ -85,10 +97,15 @@ public class HermesAccessibilityService extends AccessibilityService {
85 97 toUserDisplayName = toUser;
86 98 }
87 99  
88   - Log.i(TAG, "Received send request: to=" + toUser + " searchName=" + toUserDisplayName + " content=" + content.substring(0, Math.min(30, content.length())));
  100 + Log.i(TAG, "Received send request: to=" + toUser + " searchName=" + toUserDisplayName
  101 + + " quote=" + (quoteText != null ? quoteText.substring(0, Math.min(30, quoteText.length())) : "no")
  102 + + " atSender=" + (atSender != null ? atSender : "no")
  103 + + " content=" + content.substring(0, Math.min(30, content.length())));
89 104 pendingToUser = toUser;
90 105 pendingToUserDisplayName = toUserDisplayName;
91 106 pendingContent = content;
  107 + pendingQuoteText = (quoteText != null && !quoteText.isEmpty()) ? quoteText : null;
  108 + pendingAtSender = (atSender != null && !atSender.isEmpty()) ? atSender : null;
92 109  
93 110 // Bring WeChat to foreground (clear task to ensure main screen)
94 111 Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm");
... ... @@ -251,10 +268,156 @@ public class HermesAccessibilityService extends AccessibilityService {
251 268 }
252 269  
253 270 // =====================================================================
254   - // Step 3+4: Type message and click send
  271 + // Step 3+4: (optional quote) → (optional @ mention) → type message → click send
255 272 // =====================================================================
256 273  
257 274 private void performTypeAndSend(AccessibilityNodeInfo root) {
  275 + // 如果需要引用某条消息,先长按它弹菜单
  276 + if (pendingQuoteText != null) {
  277 + Log.i(TAG, "Quote mode: looking for bubble containing \"" +
  278 + pendingQuoteText.substring(0, Math.min(30, pendingQuoteText.length())) + "\"");
  279 + AccessibilityNodeInfo bubble = findBubbleByPartialText(root, pendingQuoteText);
  280 + if (bubble != null) {
  281 + Rect bounds = new Rect();
  282 + bubble.getBoundsInScreen(bounds);
  283 + bubble.recycle();
  284 + Log.i(TAG, "Found bubble at " + bounds + ", dispatching long-press gesture");
  285 + // 用 dispatchGesture 派发真实的 down+hold+up,因为 WeChat 用 GestureDetector
  286 + // 实现长按,AccessibilityNodeInfo.ACTION_LONG_CLICK 触发不了它
  287 + dispatchLongPress(bounds.centerX(), bounds.centerY(), 800,
  288 + () -> mainHandler.postDelayed(this::clickQuoteMenuItem, 400));
  289 + return;
  290 + } else {
  291 + Log.w(TAG, "Quote target bubble not found, falling back to plain send");
  292 + // 找不到引用目标就降级为普通文本发送
  293 + pendingQuoteText = null;
  294 + }
  295 + }
  296 +
  297 + proceedToInputAndSend(root);
  298 + }
  299 +
  300 + /**
  301 + * 派发合成长按手势 — 比 ACTION_LONG_CLICK 更可靠,因为很多 app 用
  302 + * GestureDetector / OnTouchListener 而不是标准 View.OnLongClickListener。
  303 + */
  304 + private void dispatchLongPress(int x, int y, long durationMs, Runnable onCompleted) {
  305 + Path path = new Path();
  306 + path.moveTo(x, y);
  307 + GestureDescription.StrokeDescription stroke =
  308 + new GestureDescription.StrokeDescription(path, 0, durationMs);
  309 + GestureDescription gesture = new GestureDescription.Builder()
  310 + .addStroke(stroke)
  311 + .build();
  312 + boolean dispatched = dispatchGesture(gesture, new GestureResultCallback() {
  313 + @Override
  314 + public void onCompleted(GestureDescription g) {
  315 + Log.i(TAG, "Long-press gesture completed at (" + x + "," + y + ")");
  316 + if (onCompleted != null) onCompleted.run();
  317 + }
  318 +
  319 + @Override
  320 + public void onCancelled(GestureDescription g) {
  321 + Log.w(TAG, "Long-press gesture cancelled");
  322 + if (onCompleted != null) onCompleted.run();
  323 + }
  324 + }, mainHandler);
  325 + if (!dispatched) {
  326 + Log.w(TAG, "dispatchGesture returned false");
  327 + if (onCompleted != null) onCompleted.run();
  328 + }
  329 + }
  330 +
  331 + /** 在长按菜单中点击「引用」 — 搜索所有可见窗口(菜单可能在 popup window 里) */
  332 + private void clickQuoteMenuItem() {
  333 + AccessibilityNodeInfo menuItem = findClickableByTextAllWindows(MENU_QUOTE_TEXT);
  334 + if (menuItem != null) {
  335 + Log.i(TAG, "Clicking menu item: " + MENU_QUOTE_TEXT);
  336 + menuItem.performAction(AccessibilityNodeInfo.ACTION_CLICK);
  337 + menuItem.recycle();
  338 +
  339 + // 引用 chip 注入到输入框,等界面就绪后继续 @ + 文本
  340 + mainHandler.postDelayed(() -> {
  341 + AccessibilityNodeInfo r = getRootInActiveWindow();
  342 + if (r != null) {
  343 + proceedToInputAndSend(r);
  344 + r.recycle();
  345 + }
  346 + }, 600);
  347 + } else {
  348 + Log.w(TAG, "「" + MENU_QUOTE_TEXT + "」 menu item not found in any window, dumping all visible texts");
  349 + dumpAllVisibleTexts();
  350 + pendingQuoteText = null;
  351 + recoverWithPlainSend();
  352 + }
  353 + }
  354 +
  355 + /** 遍历所有可见窗口,找 clickable + text=label 的节点 */
  356 + private AccessibilityNodeInfo findClickableByTextAllWindows(String label) {
  357 + if (label == null) return null;
  358 + List<android.view.accessibility.AccessibilityWindowInfo> windows = getWindows();
  359 + if (windows == null || windows.isEmpty()) {
  360 + AccessibilityNodeInfo r = getRootInActiveWindow();
  361 + if (r == null) return null;
  362 + AccessibilityNodeInfo result = findClickableByText(r, label);
  363 + if (result != r) r.recycle();
  364 + return result;
  365 + }
  366 + for (android.view.accessibility.AccessibilityWindowInfo w : windows) {
  367 + AccessibilityNodeInfo r = w.getRoot();
  368 + if (r == null) continue;
  369 + AccessibilityNodeInfo result = findClickableByText(r, label);
  370 + if (result != null) {
  371 + if (result != r) r.recycle();
  372 + return result;
  373 + }
  374 + r.recycle();
  375 + }
  376 + return null;
  377 + }
  378 +
  379 + /** debug:把所有可见窗口里出现的 text 打到 logcat 方便定位真实的菜单文本 */
  380 + private void dumpAllVisibleTexts() {
  381 + List<android.view.accessibility.AccessibilityWindowInfo> windows = getWindows();
  382 + if (windows == null) return;
  383 + for (android.view.accessibility.AccessibilityWindowInfo w : windows) {
  384 + AccessibilityNodeInfo r = w.getRoot();
  385 + if (r == null) continue;
  386 + Log.d(TAG, " Window: type=" + w.getType() + " title=" + w.getTitle()
  387 + + " root_class=" + r.getClassName() + " childCount=" + r.getChildCount());
  388 + collectTexts(r, 0);
  389 + r.recycle();
  390 + }
  391 + }
  392 +
  393 + private void collectTexts(AccessibilityNodeInfo n, int depth) {
  394 + if (n == null || depth > 8) return;
  395 + CharSequence t = n.getText();
  396 + if (t != null && t.length() > 0 && t.length() < 30) {
  397 + Log.d(TAG, " [d=" + depth + " clickable=" + n.isClickable() + "] " + t);
  398 + }
  399 + for (int i = 0; i < n.getChildCount(); i++) {
  400 + AccessibilityNodeInfo c = n.getChild(i);
  401 + if (c != null) {
  402 + collectTexts(c, depth + 1);
  403 + c.recycle();
  404 + }
  405 + }
  406 + }
  407 +
  408 + /** 引用步骤失败时的回退:重新拿 root,按普通文本发送 */
  409 + private void recoverWithPlainSend() {
  410 + AccessibilityNodeInfo r = getRootInActiveWindow();
  411 + if (r != null) {
  412 + proceedToInputAndSend(r);
  413 + r.recycle();
  414 + } else {
  415 + resetState();
  416 + }
  417 + }
  418 +
  419 + /** Step 3+4 真正写入文本 + 点发送(必要时先 @ 群成员) */
  420 + private void proceedToInputAndSend(AccessibilityNodeInfo root) {
258 421 AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT);
259 422 if (edit == null) {
260 423 Log.w(TAG, "Chat input not found");
... ... @@ -288,6 +451,68 @@ public class HermesAccessibilityService extends AccessibilityService {
288 451 edit.performAction(AccessibilityNodeInfo.ACTION_FOCUS);
289 452 }
290 453  
  454 + // 群里 @ 某成员:输入 "@" 触发选人面板,找名字点击
  455 + if (pendingAtSender != null) {
  456 + Log.i(TAG, "@ mention: typing @ to trigger picker for " + pendingAtSender);
  457 + Bundle atArgs = new Bundle();
  458 + atArgs.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, "@");
  459 + edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, atArgs);
  460 + // 把 edit 暂存供 typeFinalContent 用
  461 + final AccessibilityNodeInfo editRef = edit;
  462 + mainHandler.postDelayed(() -> pickMentionMemberAndContinue(editRef), 600);
  463 + return;
  464 + }
  465 +
  466 + typeFinalContent(edit);
  467 + }
  468 +
  469 + /** 在 @ 选人面板中找到对应成员并点击;失败则降级(不 @ 只发文本) */
  470 + private void pickMentionMemberAndContinue(AccessibilityNodeInfo edit) {
  471 + AccessibilityNodeInfo root = getRootInActiveWindow();
  472 + if (root == null) {
  473 + Log.w(TAG, "@ picker: root null, fallback to plain content");
  474 + pendingAtSender = null;
  475 + typeFinalContent(edit);
  476 + return;
  477 + }
  478 +
  479 + AccessibilityNodeInfo memberNode = findClickableByText(root, pendingAtSender);
  480 + if (memberNode != null) {
  481 + Log.i(TAG, "Clicking @ member: " + pendingAtSender);
  482 + memberNode.performAction(AccessibilityNodeInfo.ACTION_CLICK);
  483 + memberNode.recycle();
  484 + root.recycle();
  485 +
  486 + // 点击后 WeChat 会把 @xxx 文本插入到 EditText 末尾,等一下再继续
  487 + mainHandler.postDelayed(() -> {
  488 + AccessibilityNodeInfo r = getRootInActiveWindow();
  489 + AccessibilityNodeInfo freshEdit = (r != null) ? findInnerEditText(r) : null;
  490 + if (freshEdit != null) {
  491 + // 拼接 pendingContent 到现有 @xx 后面(不要覆盖 @ chip)
  492 + appendToEditText(freshEdit, " " + pendingContent);
  493 + freshEdit.recycle();
  494 + } else if (edit != null) {
  495 + appendToEditText(edit, " " + pendingContent);
  496 + }
  497 + if (r != null) r.recycle();
  498 + if (edit != null) edit.recycle();
  499 + mainHandler.postDelayed(() -> {
  500 + AccessibilityNodeInfo sendRoot = getRootInActiveWindow();
  501 + if (sendRoot != null) {
  502 + clickSendButton(sendRoot);
  503 + sendRoot.recycle();
  504 + }
  505 + }, 400);
  506 + }, 500);
  507 + } else {
  508 + Log.w(TAG, "@ member not found in picker: " + pendingAtSender + " — fallback to plain content");
  509 + root.recycle();
  510 + pendingAtSender = null;
  511 + typeFinalContent(edit);
  512 + }
  513 + }
  514 +
  515 + private void typeFinalContent(AccessibilityNodeInfo edit) {
291 516 Log.i(TAG, "Setting chat text: " + pendingContent.substring(0, Math.min(30, pendingContent.length())));
292 517 Bundle args = new Bundle();
293 518 args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, pendingContent);
... ... @@ -310,6 +535,33 @@ public class HermesAccessibilityService extends AccessibilityService {
310 535 }, 400);
311 536 }
312 537  
  538 + /** 在已有 @chip 的 EditText 末尾追加内容(保持 chip 不被覆盖) */
  539 + private void appendToEditText(AccessibilityNodeInfo edit, String append) {
  540 + CharSequence cur = edit.getText();
  541 + String combined = (cur != null ? cur.toString() : "") + append;
  542 + Log.i(TAG, "Append to EditText: " + append);
  543 + Bundle args = new Bundle();
  544 + args.putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, combined);
  545 + edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
  546 + }
  547 +
  548 + /** 在 bkk 容器里找内部 EditText(同样的解包逻辑封装一份方便复用) */
  549 + private AccessibilityNodeInfo findInnerEditText(AccessibilityNodeInfo root) {
  550 + AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT);
  551 + if (edit == null) return null;
  552 + if ("android.widget.EditText".equals(edit.getClassName())) return edit;
  553 + for (int i = 0; i < edit.getChildCount(); i++) {
  554 + AccessibilityNodeInfo child = edit.getChild(i);
  555 + if (child != null && "android.widget.EditText".equals(child.getClassName())) {
  556 + edit.recycle();
  557 + return child;
  558 + }
  559 + if (child != null) child.recycle();
  560 + }
  561 + edit.recycle();
  562 + return null;
  563 + }
  564 +
313 565 private void clickSendButton(AccessibilityNodeInfo root) {
314 566 // Primary: find by exact ID
315 567 AccessibilityNodeInfo sendBtn = findNodeById(root, ID_CHAT_SEND);
... ... @@ -354,10 +606,71 @@ public class HermesAccessibilityService extends AccessibilityService {
354 606 return null;
355 607 }
356 608  
  609 + /**
  610 + * 在所有 bkl 消息气泡里找文本包含 partial(子串)的最后一条(最近收到的那条优先)。
  611 + */
  612 + private AccessibilityNodeInfo findBubbleByPartialText(AccessibilityNodeInfo root, String partial) {
  613 + if (root == null || partial == null || partial.isEmpty()) return null;
  614 + List<AccessibilityNodeInfo> bubbles = root.findAccessibilityNodeInfosByViewId(ID_BUBBLE_TEXT);
  615 + Log.d(TAG, "findBubbleByPartialText: found " + (bubbles == null ? 0 : bubbles.size()) + " bkl bubbles, looking for: " + partial);
  616 + if (bubbles == null || bubbles.isEmpty()) return null;
  617 + AccessibilityNodeInfo match = null;
  618 + // 后面的 bubble 在列表里通常更靠后(也更新),倒序优先
  619 + for (int i = bubbles.size() - 1; i >= 0; i--) {
  620 + AccessibilityNodeInfo b = bubbles.get(i);
  621 + CharSequence t = b.getText();
  622 + String tStr = (t != null) ? t.toString() : "(null)";
  623 + String preview = tStr.length() > 40 ? tStr.substring(0, 40) + "…" : tStr;
  624 + Log.d(TAG, " bubble[" + i + "] text=" + preview);
  625 + if (match == null && t != null && t.toString().contains(partial)) {
  626 + Log.i(TAG, " matched at index " + i);
  627 + match = b;
  628 + continue;
  629 + }
  630 + b.recycle();
  631 + }
  632 + return match;
  633 + }
  634 +
  635 + /**
  636 + * 在 root 子树里找一个 text 等于 label 且 clickable(自身或最近祖先)的节点。
  637 + * 用于点击弹出菜单 / 选人面板里的项。
  638 + */
  639 + private AccessibilityNodeInfo findClickableByText(AccessibilityNodeInfo root, String label) {
  640 + if (root == null || label == null) return null;
  641 + List<AccessibilityNodeInfo> nodes = root.findAccessibilityNodeInfosByText(label);
  642 + if (nodes == null || nodes.isEmpty()) return null;
  643 + for (AccessibilityNodeInfo n : nodes) {
  644 + CharSequence t = n.getText();
  645 + if (t == null) { n.recycle(); continue; }
  646 + // 严格匹配避免误命中("引用复制删除…" 这类长 text)
  647 + if (!label.equals(t.toString())) { n.recycle(); continue; }
  648 + // 自身可点就用自身
  649 + if (n.isClickable()) return n;
  650 + // 否则向上找 clickable 祖先
  651 + AccessibilityNodeInfo cur = n;
  652 + AccessibilityNodeInfo result = null;
  653 + while (cur != null) {
  654 + if (cur.isClickable()) { result = cur; break; }
  655 + AccessibilityNodeInfo parent = cur.getParent();
  656 + if (cur != n) cur.recycle();
  657 + cur = parent;
  658 + }
  659 + if (result != null) {
  660 + if (result != n) n.recycle();
  661 + return result;
  662 + }
  663 + n.recycle();
  664 + }
  665 + return null;
  666 + }
  667 +
357 668 private void resetState() {
358 669 pendingToUser = null;
359 670 pendingToUserDisplayName = null;
360 671 pendingContent = null;
  672 + pendingQuoteText = null;
  673 + pendingAtSender = null;
361 674 Log.i(TAG, "State reset");
362 675 }
363 676 }
... ...
wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/res/xml/accessibility_service_config.xml
... ... @@ -5,4 +5,5 @@
5 5 android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewFocused"
6 6 android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows|flagReportViewIds"
7 7 android:canRetrieveWindowContent="true"
  8 + android:canPerformGestures="true"
8 9 android:notificationTimeout="100" />
... ...