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,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) { |