Commit 508fbfb348402b3ea6c0a298919b123c8a0037cc
1 parent
423e2229
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>
Showing
4 changed files
with
349 additions
and
7 deletions
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" /> | ... | ... |