From b5275c101885c6ec94342ff5ec8deb0d0ae505e6 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 24 May 2026 22:19:32 +0800 Subject: [PATCH] refactor: A11y 入口改为显式状态机(classify → dispatch → re-classify) --- wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java | 309 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------------------------- 1 file changed, 203 insertions(+), 106 deletions(-) diff --git a/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java b/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java index 3f0af15..cfa6d4d 100644 --- a/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java +++ b/wechat_plain/scripts/lsposed-wechat-sendmsg/app/src/main/java/com/hermes/wechat/xposed/accessibility/HermesAccessibilityService.java @@ -109,136 +109,233 @@ public class HermesAccessibilityService extends AccessibilityService { pendingQuoteText = (quoteText != null && !quoteText.isEmpty()) ? quoteText : null; pendingAtSender = (atSender != null && !atSender.isEmpty()) ? atSender : null; - // Bring WeChat to foreground (clear task to ensure main screen) + // 拉起微信主界面(如果不在则会启动;如果在则保留当前 fragment 状态) Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm"); if (launch != null) { - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); startActivity(launch); } - // Poll UI state after WeChat loads - mainHandler.postDelayed(() -> { - AccessibilityNodeInfo root = getRootInActiveWindow(); - if (root == null) { - Log.w(TAG, "Root is null after launch"); - resetState(); - return; - } + // 等微信前台后启动状态机 + mainHandler.postDelayed(() -> driveStateMachine(0), 3500); + } + }; + + // ===================================================================== + // WeChat UI state machine + // ===================================================================== + // + // 微信能停留的页面种类多(主 tab × 4、聊天页正/错、各类子页、不在微信), + // 旧代码用嵌套 if + relaunch 拼凑容易死角。状态机思路: + // + // 1. classifyState(root) 把当前 root 归到 6 个枚举之一 + // 2. switch dispatch 到对应 handler:执行一次动作(点击 / BACK / 启动) + // 3. 动作后 postDelayed 一段时间,递归 driveStateMachine 再 classify + // 4. 总跳转次数 cap 防止死循环(异常状态走 resetState 兜底) + // + // 状态划分: + // TARGET_CHAT bkk + obn.text == displayName → 直接 type/send + // WRONG_CHAT bkk + obn.text != displayName → BACK 一次 + // CHAT_LIST 无 bkk,kbq 多个 → 点 cj1 row + // NO_CHAT_VIEW 无 bkk 无 kbq,仍在 com.tencent.mm → 点底部"微信"tab, + // 否则 BACK + // OUTSIDE_WECHAT root.pkg != com.tencent.mm → startActivity + // ===================================================================== - CharSequence pkg = root.getPackageName(); - Log.i(TAG, "Root package=" + pkg); + private static final int MAX_STATE_TRANSITIONS = 6; - AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT); - AccessibilityNodeInfo title = findNodeById(root, ID_CHAT_TITLE); - boolean inCorrectChat = false; - if (edit != null && title != null) { - CharSequence titleText = title.getText(); - inCorrectChat = pendingToUserDisplayName.equals(titleText != null ? titleText.toString() : ""); - title.recycle(); - } - if (edit != null && inCorrectChat) { - Log.i(TAG, "Already in correct chat, typing directly"); - performTypeAndSend(root); + private enum WeChatState { + TARGET_CHAT, WRONG_CHAT, CHAT_LIST, NO_CHAT_VIEW, OUTSIDE_WECHAT + } + + private WeChatState classifyState(AccessibilityNodeInfo root) { + if (root == null) return WeChatState.OUTSIDE_WECHAT; + CharSequence pkg = root.getPackageName(); + if (pkg == null || !"com.tencent.mm".contentEquals(pkg)) { + return WeChatState.OUTSIDE_WECHAT; + } + + AccessibilityNodeInfo bkk = findNodeById(root, ID_CHAT_EDIT); + if (bkk != null) { + AccessibilityNodeInfo title = findNodeById(root, ID_CHAT_TITLE); + boolean isTarget = false; + if (title != null) { + CharSequence t = title.getText(); + isTarget = pendingToUserDisplayName != null + && pendingToUserDisplayName.equals(t != null ? t.toString() : ""); + title.recycle(); + } + bkk.recycle(); + return isTarget ? WeChatState.TARGET_CHAT : WeChatState.WRONG_CHAT; + } + + List names = root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME); + int kbqCount = (names == null ? 0 : names.size()); + if (names != null) for (AccessibilityNodeInfo n : names) n.recycle(); + if (kbqCount > 0) return WeChatState.CHAT_LIST; + + return WeChatState.NO_CHAT_VIEW; + } + + private void driveStateMachine(int transitionCount) { + if (transitionCount >= MAX_STATE_TRANSITIONS) { + Log.w(TAG, "State machine cap reached (" + MAX_STATE_TRANSITIONS + "), giving up"); + resetState(); + return; + } + + AccessibilityNodeInfo root = getRootInActiveWindow(); + WeChatState st = classifyState(root); + Log.i(TAG, "FSM[" + transitionCount + "] state=" + st + + " target=" + pendingToUserDisplayName); + + switch (st) { + case TARGET_CHAT: { + Log.i(TAG, "FSM: in target chat, performTypeAndSend"); + performTypeAndSend(root); + return; + } + + case CHAT_LIST: { + AccessibilityNodeInfo row = findChatListRow(root, pendingToUserDisplayName); + if (row != null) { + Log.i(TAG, "FSM: clicking chat list row for " + pendingToUserDisplayName); + row.performAction(AccessibilityNodeInfo.ACTION_CLICK); + row.recycle(); + root.recycle(); + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 2200); } else { - if (edit != null) { - Log.i(TAG, "In wrong chat (title mismatch), restarting WeChat main screen"); - Intent relaunch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm"); - if (relaunch != null) { - relaunch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(relaunch); - } - mainHandler.postDelayed(() -> { - AccessibilityNodeInfo mainRoot = getRootInActiveWindow(); - if (mainRoot != null) { - enterChatViaListOrSearch(mainRoot); - mainRoot.recycle(); - } - }, 1500); - } else { - enterChatViaListOrSearch(root); - } + Log.w(TAG, "FSM: " + pendingToUserDisplayName + + " not in visible chat list, falling back to search"); + performSearch(root); + // performSearch owns root from here } + return; + } + + case WRONG_CHAT: { + Log.i(TAG, "FSM: in wrong chat, GLOBAL_ACTION_BACK"); root.recycle(); - }, 3500); - } - }; + performGlobalAction(GLOBAL_ACTION_BACK); + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 800); + return; + } - // ===================================================================== - // Enter chat via chat list (preferred) — fallback to search - // ===================================================================== + case NO_CHAT_VIEW: { + // 优先点底部"微信"tab;点不了就 BACK + AccessibilityNodeInfo weixinTab = findBottomNavTab(root, "微信"); + root.recycle(); + if (weixinTab != null) { + Log.i(TAG, "FSM: NO_CHAT_VIEW, clicking bottom '微信' tab"); + weixinTab.performAction(AccessibilityNodeInfo.ACTION_CLICK); + weixinTab.recycle(); + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 800); + } else { + Log.i(TAG, "FSM: NO_CHAT_VIEW, no '微信' tab, GLOBAL_ACTION_BACK"); + performGlobalAction(GLOBAL_ACTION_BACK); + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 800); + } + return; + } - /** - * 优先方案:在 LauncherUI 的 chat list 里找显示名匹配的行,直接点击进入。 - * 比搜索路径稳得多 — 不会触发"创建群聊"歧义(搜索"丁鹏、🍋火星人、火星人" - * 会被 WeChat 解读成"创建包含这 3 人的新群"建议)。 - * - * 失败时(chat 不在可见列表里 / 不在 LauncherUI 等)回退到搜索路径。 - */ - private void enterChatViaListOrSearch(AccessibilityNodeInfo root) { - // 1) 找 chat list 里所有 kbq TextView,按 text 严格匹配 displayName + case OUTSIDE_WECHAT: { + Log.i(TAG, "FSM: outside WeChat, launching LauncherUI"); + if (root != null) root.recycle(); + Intent re = getPackageManager().getLaunchIntentForPackage("com.tencent.mm"); + if (re != null) { + re.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(re); + } + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 3500); + return; + } + } + } + + /** 在 root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME) 里找 displayName 严格匹配, + * 向上回溯到 cj1 row 容器作为可点击目标。 */ + private AccessibilityNodeInfo findChatListRow(AccessibilityNodeInfo root, String displayName) { + if (root == null || displayName == null) return null; List names = root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME); - Log.d(TAG, "ChatList scan: found " + (names == null ? 0 : names.size()) + " kbq names, target=" + pendingToUserDisplayName); - AccessibilityNodeInfo matchedRow = null; - if (names != null) { - for (AccessibilityNodeInfo n : names) { - CharSequence t = n.getText(); - if (matchedRow == null && t != null && pendingToUserDisplayName.equals(t.toString())) { - // 向上找 cj1 (clickable row container) - AccessibilityNodeInfo cur = n; - while (cur != null) { - CharSequence id = cur.getViewIdResourceName(); - if (id != null && ID_CHATLIST_ROW.equals(id.toString())) { - matchedRow = cur; - break; - } - AccessibilityNodeInfo parent = cur.getParent(); - if (cur != n) cur.recycle(); - cur = parent; + if (names == null) return null; + AccessibilityNodeInfo matched = null; + for (AccessibilityNodeInfo n : names) { + CharSequence t = n.getText(); + if (matched == null && t != null && displayName.equals(t.toString())) { + // 向上找 cj1 + AccessibilityNodeInfo cur = n; + while (cur != null) { + CharSequence id = cur.getViewIdResourceName(); + if (id != null && ID_CHATLIST_ROW.equals(id.toString())) { + matched = cur; + break; } - // fallback: 没找到 cj1 就用任意 clickable 祖先 - if (matchedRow == null) { - AccessibilityNodeInfo c = n; - while (c != null) { - if (c.isClickable()) { matchedRow = c; break; } - AccessibilityNodeInfo p = c.getParent(); - if (c != n) c.recycle(); - c = p; - } + AccessibilityNodeInfo parent = cur.getParent(); + if (cur != n) cur.recycle(); + cur = parent; + } + // fallback: 任意 clickable 祖先 + if (matched == null) { + AccessibilityNodeInfo c = n; + while (c != null) { + if (c.isClickable()) { matched = c; break; } + AccessibilityNodeInfo p = c.getParent(); + if (c != n) c.recycle(); + c = p; } - continue; // matched, don't recycle n } - n.recycle(); + continue; } + n.recycle(); } + return matched; + } - if (matchedRow != null) { - Log.i(TAG, "ChatList: clicking row for " + pendingToUserDisplayName); - matchedRow.performAction(AccessibilityNodeInfo.ACTION_CLICK); - matchedRow.recycle(); + /** 找底部 nav 的指定文本 tab("微信" / "通讯录" / "发现" / "我")的可点击祖先。 + * 这些 tab 可能 text 同名节点不止一个(如标题栏也叫"微信"),优先选屏幕**最下方**的。 */ + private AccessibilityNodeInfo findBottomNavTab(AccessibilityNodeInfo root, String tabText) { + if (root == null || tabText == null) return null; + List nodes = root.findAccessibilityNodeInfosByText(tabText); + if (nodes == null || nodes.isEmpty()) return null; - // 等聊天页加载,bkk 出现后开始 quote/@/text 流程 - mainHandler.postDelayed(() -> { - AccessibilityNodeInfo chatRoot = getRootInActiveWindow(); - if (chatRoot != null) { - if (findNodeById(chatRoot, ID_CHAT_EDIT) != null) { - Log.i(TAG, "Chat UI loaded from list, proceeding to type/send"); - performTypeAndSend(chatRoot); - } else { - Log.w(TAG, "Chat UI not loaded after list click, falling back to search"); - performSearch(chatRoot); - } - chatRoot.recycle(); - } - }, 2200); - return; - } + AccessibilityNodeInfo bestClickable = null; + int bestY = -1; + for (AccessibilityNodeInfo n : nodes) { + CharSequence t = n.getText(); + if (t == null || !tabText.equals(t.toString())) { n.recycle(); continue; } + + Rect b = new Rect(); + n.getBoundsInScreen(b); + + // 向上找 clickable 祖先 + AccessibilityNodeInfo clickable = null; + AccessibilityNodeInfo cur = n; + while (cur != null) { + if (cur.isClickable()) { clickable = cur; break; } + AccessibilityNodeInfo p = cur.getParent(); + if (cur != n) cur.recycle(); + cur = p; + } + if (clickable == null) { + n.recycle(); + continue; + } - Log.i(TAG, "ChatList: no match for " + pendingToUserDisplayName + " in visible list, falling back to search"); - performSearch(root); + // 选 Y 坐标最大的(即屏幕最下方的那个 = 底部 nav) + if (b.top > bestY) { + if (bestClickable != null) bestClickable.recycle(); + bestClickable = clickable; + bestY = b.top; + } else { + if (clickable != n) clickable.recycle(); + } + // n 已被 clickable 链接管理或在 if 上面 recycle 过 + } + return bestClickable; } // ===================================================================== - // Search for contact (fallback path) + // Search for contact (fallback when chat list doesn't contain target) // ===================================================================== private void performSearch(AccessibilityNodeInfo root) { -- libgit2 0.21.4