Commit 8648cd322a08ca8d613eaa5f5782abb5eabe28ca
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>
Showing
1 changed file
with
203 additions
and
106 deletions
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) { | ... | ... |