Commit b5275c101885c6ec94342ff5ec8deb0d0ae505e6

Authored by yibo
1 parent 10b9d92c

refactor: A11y 入口改为显式状态机(classify → dispatch → re-classify)

旧代码的 onReceive postDelayed 块里嵌套 if + ad-hoc relaunch 逻辑,
覆盖不到几种边界情形:
- 微信停在非"会话"主 tab(通讯录/发现/我),CLEAR_TASK 不重置 LauncherUI
  内部 fragment,导致 chat list 扫到 0 个 kbq 后只能 fallback 搜索
- 在错误聊天页(用户上次跟别人聊到一半),CLEAR_TASK 也保留 fragment
- 在子页(搜索/创建群/朋友圈等),需要 BACK 才能退出

新实现:状态机
  WeChatState ∈ {TARGET_CHAT, WRONG_CHAT, CHAT_LIST, NO_CHAT_VIEW, OUTSIDE_WECHAT}

  classifyState(root): O(1) 检测 bkk / obn / kbq / pkg.name → 5 状态
  driveStateMachine(n): switch dispatch + postDelayed 递归
    - TARGET_CHAT  → performTypeAndSend (终态)
    - WRONG_CHAT   → GLOBAL_ACTION_BACK → 再 classify
    - CHAT_LIST    → findChatListRow + click cj1 → 再 classify (→ TARGET_CHAT)
    - NO_CHAT_VIEW → 优先 findBottomNavTab("微信") click(Y 最大者),fallback BACK
    - OUTSIDE_WECHAT → startActivity LauncherUI → 再 classify
  MAX_STATE_TRANSITIONS = 6 防死循环

新增辅助:
  findChatListRow(root, name): kbq strict equals → 回溯找 cj1 ancestor
  findBottomNavTab(root, text): findByText + 选 Y 坐标最大的 clickable 祖先
    (区分底部 nav 与标题栏 / 联系人列表里的同名 text)

删除:enterChatViaListOrSearch (被状态机吸收)

实测(从群聊页起步,FSM[0]→[3] 共 8s):
  FSM[0] state=WRONG_CHAT target=火星人 → BACK
  FSM[1] state=WRONG_CHAT target=火星人 → BACK
  FSM[2] state=CHAT_LIST  target=火星人 → click cj1
  FSM[3] state=TARGET_CHAT target=火星人 → performTypeAndSend ✅

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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 {
109 109 pendingQuoteText = (quoteText != null && !quoteText.isEmpty()) ? quoteText : null;
110 110 pendingAtSender = (atSender != null && !atSender.isEmpty()) ? atSender : null;
111 111  
112   - // Bring WeChat to foreground (clear task to ensure main screen)
  112 + // 拉起微信主界面(如果不在则会启动;如果在则保留当前 fragment 状态)
113 113 Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm");
114 114 if (launch != null) {
115   - launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
  115 + launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
116 116 startActivity(launch);
117 117 }
118 118  
119   - // Poll UI state after WeChat loads
120   - mainHandler.postDelayed(() -> {
121   - AccessibilityNodeInfo root = getRootInActiveWindow();
122   - if (root == null) {
123   - Log.w(TAG, "Root is null after launch");
124   - resetState();
125   - return;
126   - }
  119 + // 等微信前台后启动状态机
  120 + mainHandler.postDelayed(() -> driveStateMachine(0), 3500);
  121 + }
  122 + };
  123 +
  124 + // =====================================================================
  125 + // WeChat UI state machine
  126 + // =====================================================================
  127 + //
  128 + // 微信能停留的页面种类多(主 tab × 4、聊天页正/错、各类子页、不在微信),
  129 + // 旧代码用嵌套 if + relaunch 拼凑容易死角。状态机思路:
  130 + //
  131 + // 1. classifyState(root) 把当前 root 归到 6 个枚举之一
  132 + // 2. switch dispatch 到对应 handler:执行一次动作(点击 / BACK / 启动)
  133 + // 3. 动作后 postDelayed 一段时间,递归 driveStateMachine 再 classify
  134 + // 4. 总跳转次数 cap 防止死循环(异常状态走 resetState 兜底)
  135 + //
  136 + // 状态划分:
  137 + // TARGET_CHAT bkk + obn.text == displayName → 直接 type/send
  138 + // WRONG_CHAT bkk + obn.text != displayName → BACK 一次
  139 + // CHAT_LIST 无 bkk,kbq 多个 → 点 cj1 row
  140 + // NO_CHAT_VIEW 无 bkk 无 kbq,仍在 com.tencent.mm → 点底部"微信"tab,
  141 + // 否则 BACK
  142 + // OUTSIDE_WECHAT root.pkg != com.tencent.mm → startActivity
  143 + // =====================================================================
127 144  
128   - CharSequence pkg = root.getPackageName();
129   - Log.i(TAG, "Root package=" + pkg);
  145 + private static final int MAX_STATE_TRANSITIONS = 6;
130 146  
131   - AccessibilityNodeInfo edit = findNodeById(root, ID_CHAT_EDIT);
132   - AccessibilityNodeInfo title = findNodeById(root, ID_CHAT_TITLE);
133   - boolean inCorrectChat = false;
134   - if (edit != null && title != null) {
135   - CharSequence titleText = title.getText();
136   - inCorrectChat = pendingToUserDisplayName.equals(titleText != null ? titleText.toString() : "");
137   - title.recycle();
138   - }
139   - if (edit != null && inCorrectChat) {
140   - Log.i(TAG, "Already in correct chat, typing directly");
141   - performTypeAndSend(root);
  147 + private enum WeChatState {
  148 + TARGET_CHAT, WRONG_CHAT, CHAT_LIST, NO_CHAT_VIEW, OUTSIDE_WECHAT
  149 + }
  150 +
  151 + private WeChatState classifyState(AccessibilityNodeInfo root) {
  152 + if (root == null) return WeChatState.OUTSIDE_WECHAT;
  153 + CharSequence pkg = root.getPackageName();
  154 + if (pkg == null || !"com.tencent.mm".contentEquals(pkg)) {
  155 + return WeChatState.OUTSIDE_WECHAT;
  156 + }
  157 +
  158 + AccessibilityNodeInfo bkk = findNodeById(root, ID_CHAT_EDIT);
  159 + if (bkk != null) {
  160 + AccessibilityNodeInfo title = findNodeById(root, ID_CHAT_TITLE);
  161 + boolean isTarget = false;
  162 + if (title != null) {
  163 + CharSequence t = title.getText();
  164 + isTarget = pendingToUserDisplayName != null
  165 + && pendingToUserDisplayName.equals(t != null ? t.toString() : "");
  166 + title.recycle();
  167 + }
  168 + bkk.recycle();
  169 + return isTarget ? WeChatState.TARGET_CHAT : WeChatState.WRONG_CHAT;
  170 + }
  171 +
  172 + List<AccessibilityNodeInfo> names = root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME);
  173 + int kbqCount = (names == null ? 0 : names.size());
  174 + if (names != null) for (AccessibilityNodeInfo n : names) n.recycle();
  175 + if (kbqCount > 0) return WeChatState.CHAT_LIST;
  176 +
  177 + return WeChatState.NO_CHAT_VIEW;
  178 + }
  179 +
  180 + private void driveStateMachine(int transitionCount) {
  181 + if (transitionCount >= MAX_STATE_TRANSITIONS) {
  182 + Log.w(TAG, "State machine cap reached (" + MAX_STATE_TRANSITIONS + "), giving up");
  183 + resetState();
  184 + return;
  185 + }
  186 +
  187 + AccessibilityNodeInfo root = getRootInActiveWindow();
  188 + WeChatState st = classifyState(root);
  189 + Log.i(TAG, "FSM[" + transitionCount + "] state=" + st
  190 + + " target=" + pendingToUserDisplayName);
  191 +
  192 + switch (st) {
  193 + case TARGET_CHAT: {
  194 + Log.i(TAG, "FSM: in target chat, performTypeAndSend");
  195 + performTypeAndSend(root);
  196 + return;
  197 + }
  198 +
  199 + case CHAT_LIST: {
  200 + AccessibilityNodeInfo row = findChatListRow(root, pendingToUserDisplayName);
  201 + if (row != null) {
  202 + Log.i(TAG, "FSM: clicking chat list row for " + pendingToUserDisplayName);
  203 + row.performAction(AccessibilityNodeInfo.ACTION_CLICK);
  204 + row.recycle();
  205 + root.recycle();
  206 + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 2200);
142 207 } else {
143   - if (edit != null) {
144   - Log.i(TAG, "In wrong chat (title mismatch), restarting WeChat main screen");
145   - Intent relaunch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm");
146   - if (relaunch != null) {
147   - relaunch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
148   - startActivity(relaunch);
149   - }
150   - mainHandler.postDelayed(() -> {
151   - AccessibilityNodeInfo mainRoot = getRootInActiveWindow();
152   - if (mainRoot != null) {
153   - enterChatViaListOrSearch(mainRoot);
154   - mainRoot.recycle();
155   - }
156   - }, 1500);
157   - } else {
158   - enterChatViaListOrSearch(root);
159   - }
  208 + Log.w(TAG, "FSM: " + pendingToUserDisplayName
  209 + + " not in visible chat list, falling back to search");
  210 + performSearch(root);
  211 + // performSearch owns root from here
160 212 }
  213 + return;
  214 + }
  215 +
  216 + case WRONG_CHAT: {
  217 + Log.i(TAG, "FSM: in wrong chat, GLOBAL_ACTION_BACK");
161 218 root.recycle();
162   - }, 3500);
163   - }
164   - };
  219 + performGlobalAction(GLOBAL_ACTION_BACK);
  220 + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 800);
  221 + return;
  222 + }
165 223  
166   - // =====================================================================
167   - // Enter chat via chat list (preferred) — fallback to search
168   - // =====================================================================
  224 + case NO_CHAT_VIEW: {
  225 + // 优先点底部"微信"tab;点不了就 BACK
  226 + AccessibilityNodeInfo weixinTab = findBottomNavTab(root, "微信");
  227 + root.recycle();
  228 + if (weixinTab != null) {
  229 + Log.i(TAG, "FSM: NO_CHAT_VIEW, clicking bottom '微信' tab");
  230 + weixinTab.performAction(AccessibilityNodeInfo.ACTION_CLICK);
  231 + weixinTab.recycle();
  232 + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 800);
  233 + } else {
  234 + Log.i(TAG, "FSM: NO_CHAT_VIEW, no '微信' tab, GLOBAL_ACTION_BACK");
  235 + performGlobalAction(GLOBAL_ACTION_BACK);
  236 + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 800);
  237 + }
  238 + return;
  239 + }
169 240  
170   - /**
171   - * 优先方案:在 LauncherUI 的 chat list 里找显示名匹配的行,直接点击进入。
172   - * 比搜索路径稳得多 — 不会触发"创建群聊"歧义(搜索"丁鹏、🍋火星人、火星人"
173   - * 会被 WeChat 解读成"创建包含这 3 人的新群"建议)。
174   - *
175   - * 失败时(chat 不在可见列表里 / 不在 LauncherUI 等)回退到搜索路径。
176   - */
177   - private void enterChatViaListOrSearch(AccessibilityNodeInfo root) {
178   - // 1) 找 chat list 里所有 kbq TextView,按 text 严格匹配 displayName
  241 + case OUTSIDE_WECHAT: {
  242 + Log.i(TAG, "FSM: outside WeChat, launching LauncherUI");
  243 + if (root != null) root.recycle();
  244 + Intent re = getPackageManager().getLaunchIntentForPackage("com.tencent.mm");
  245 + if (re != null) {
  246 + re.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
  247 + startActivity(re);
  248 + }
  249 + mainHandler.postDelayed(() -> driveStateMachine(transitionCount + 1), 3500);
  250 + return;
  251 + }
  252 + }
  253 + }
  254 +
  255 + /** 在 root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME) 里找 displayName 严格匹配,
  256 + * 向上回溯到 cj1 row 容器作为可点击目标。 */
  257 + private AccessibilityNodeInfo findChatListRow(AccessibilityNodeInfo root, String displayName) {
  258 + if (root == null || displayName == null) return null;
179 259 List<AccessibilityNodeInfo> names = root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME);
180   - Log.d(TAG, "ChatList scan: found " + (names == null ? 0 : names.size()) + " kbq names, target=" + pendingToUserDisplayName);
181   - AccessibilityNodeInfo matchedRow = null;
182   - if (names != null) {
183   - for (AccessibilityNodeInfo n : names) {
184   - CharSequence t = n.getText();
185   - if (matchedRow == null && t != null && pendingToUserDisplayName.equals(t.toString())) {
186   - // 向上找 cj1 (clickable row container)
187   - AccessibilityNodeInfo cur = n;
188   - while (cur != null) {
189   - CharSequence id = cur.getViewIdResourceName();
190   - if (id != null && ID_CHATLIST_ROW.equals(id.toString())) {
191   - matchedRow = cur;
192   - break;
193   - }
194   - AccessibilityNodeInfo parent = cur.getParent();
195   - if (cur != n) cur.recycle();
196   - cur = parent;
  260 + if (names == null) return null;
  261 + AccessibilityNodeInfo matched = null;
  262 + for (AccessibilityNodeInfo n : names) {
  263 + CharSequence t = n.getText();
  264 + if (matched == null && t != null && displayName.equals(t.toString())) {
  265 + // 向上找 cj1
  266 + AccessibilityNodeInfo cur = n;
  267 + while (cur != null) {
  268 + CharSequence id = cur.getViewIdResourceName();
  269 + if (id != null && ID_CHATLIST_ROW.equals(id.toString())) {
  270 + matched = cur;
  271 + break;
197 272 }
198   - // fallback: 没找到 cj1 就用任意 clickable 祖先
199   - if (matchedRow == null) {
200   - AccessibilityNodeInfo c = n;
201   - while (c != null) {
202   - if (c.isClickable()) { matchedRow = c; break; }
203   - AccessibilityNodeInfo p = c.getParent();
204   - if (c != n) c.recycle();
205   - c = p;
206   - }
  273 + AccessibilityNodeInfo parent = cur.getParent();
  274 + if (cur != n) cur.recycle();
  275 + cur = parent;
  276 + }
  277 + // fallback: 任意 clickable 祖先
  278 + if (matched == null) {
  279 + AccessibilityNodeInfo c = n;
  280 + while (c != null) {
  281 + if (c.isClickable()) { matched = c; break; }
  282 + AccessibilityNodeInfo p = c.getParent();
  283 + if (c != n) c.recycle();
  284 + c = p;
207 285 }
208   - continue; // matched, don't recycle n
209 286 }
210   - n.recycle();
  287 + continue;
211 288 }
  289 + n.recycle();
212 290 }
  291 + return matched;
  292 + }
213 293  
214   - if (matchedRow != null) {
215   - Log.i(TAG, "ChatList: clicking row for " + pendingToUserDisplayName);
216   - matchedRow.performAction(AccessibilityNodeInfo.ACTION_CLICK);
217   - matchedRow.recycle();
  294 + /** 找底部 nav 的指定文本 tab("微信" / "通讯录" / "发现" / "我")的可点击祖先。
  295 + * 这些 tab 可能 text 同名节点不止一个(如标题栏也叫"微信"),优先选屏幕**最下方**的。 */
  296 + private AccessibilityNodeInfo findBottomNavTab(AccessibilityNodeInfo root, String tabText) {
  297 + if (root == null || tabText == null) return null;
  298 + List<AccessibilityNodeInfo> nodes = root.findAccessibilityNodeInfosByText(tabText);
  299 + if (nodes == null || nodes.isEmpty()) return null;
218 300  
219   - // 等聊天页加载,bkk 出现后开始 quote/@/text 流程
220   - mainHandler.postDelayed(() -> {
221   - AccessibilityNodeInfo chatRoot = getRootInActiveWindow();
222   - if (chatRoot != null) {
223   - if (findNodeById(chatRoot, ID_CHAT_EDIT) != null) {
224   - Log.i(TAG, "Chat UI loaded from list, proceeding to type/send");
225   - performTypeAndSend(chatRoot);
226   - } else {
227   - Log.w(TAG, "Chat UI not loaded after list click, falling back to search");
228   - performSearch(chatRoot);
229   - }
230   - chatRoot.recycle();
231   - }
232   - }, 2200);
233   - return;
234   - }
  301 + AccessibilityNodeInfo bestClickable = null;
  302 + int bestY = -1;
  303 + for (AccessibilityNodeInfo n : nodes) {
  304 + CharSequence t = n.getText();
  305 + if (t == null || !tabText.equals(t.toString())) { n.recycle(); continue; }
  306 +
  307 + Rect b = new Rect();
  308 + n.getBoundsInScreen(b);
  309 +
  310 + // 向上找 clickable 祖先
  311 + AccessibilityNodeInfo clickable = null;
  312 + AccessibilityNodeInfo cur = n;
  313 + while (cur != null) {
  314 + if (cur.isClickable()) { clickable = cur; break; }
  315 + AccessibilityNodeInfo p = cur.getParent();
  316 + if (cur != n) cur.recycle();
  317 + cur = p;
  318 + }
  319 + if (clickable == null) {
  320 + n.recycle();
  321 + continue;
  322 + }
235 323  
236   - Log.i(TAG, "ChatList: no match for " + pendingToUserDisplayName + " in visible list, falling back to search");
237   - performSearch(root);
  324 + // 选 Y 坐标最大的(即屏幕最下方的那个 = 底部 nav)
  325 + if (b.top > bestY) {
  326 + if (bestClickable != null) bestClickable.recycle();
  327 + bestClickable = clickable;
  328 + bestY = b.top;
  329 + } else {
  330 + if (clickable != n) clickable.recycle();
  331 + }
  332 + // n 已被 clickable 链接管理或在 if 上面 recycle 过
  333 + }
  334 + return bestClickable;
238 335 }
239 336  
240 337 // =====================================================================
241   - // Search for contact (fallback path)
  338 + // Search for contact (fallback when chat list doesn't contain target)
242 339 // =====================================================================
243 340  
244 341 private void performSearch(AccessibilityNodeInfo root) {
... ...