Commit 8648cd322a08ca8d613eaa5f5782abb5eabe28ca

Authored by yibo
1 parent 9d680159

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,136 +109,233 @@ public class HermesAccessibilityService extends AccessibilityService {
109 pendingQuoteText = (quoteText != null && !quoteText.isEmpty()) ? quoteText : null; 109 pendingQuoteText = (quoteText != null && !quoteText.isEmpty()) ? quoteText : null;
110 pendingAtSender = (atSender != null && !atSender.isEmpty()) ? atSender : null; 110 pendingAtSender = (atSender != null && !atSender.isEmpty()) ? atSender : null;
111 111
112 - // Bring WeChat to foreground (clear task to ensure main screen) 112 + // 拉起微信主界面(如果不在则会启动;如果在则保留当前 fragment 状态)
113 Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm"); 113 Intent launch = getPackageManager().getLaunchIntentForPackage("com.tencent.mm");
114 if (launch != null) { 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 startActivity(launch); 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 } else { 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 root.recycle(); 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 List<AccessibilityNodeInfo> names = root.findAccessibilityNodeInfosByViewId(ID_CHATLIST_NAME); 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 private void performSearch(AccessibilityNodeInfo root) { 341 private void performSearch(AccessibilityNodeInfo root) {