# 无障碍服务与微信文本抓取指南 (Accessibility & WeChat Text Extraction)

本文记录 DriftLedger 无障碍服务（`plugins/accessibility/`）抓取微信账单页面文本的完整方案，重点沉淀**绕过微信 8.0.52+ 节点混淆**的伪装机制、Config Plugin 的落地细节、调试方法论与踩过的坑。

> 配套：OCR 管线见 `ocr-pipeline-guide.md`；构建/编译陷阱见 `android-build-guide.md`。

---

## 0. TL;DR

- 微信 8.0.52+ 对第三方无障碍服务做**节点混淆**：把页面节点的 `text` / `contentDescription` 用其他节点信息随机替换，导致基于节点文本的解析全部失效。微信内部有**白名单**：系统服务（TalkBack、SelectToSpeak）不受影响。
- 绕过方案：**伪装成系统 SelectToSpeak 服务**。微信按 `ComponentName`（`包名/类名`）识别白名单，所以必须**包名 + 类名同时伪装**为 `com.google.android.accessibility.selecttospeak.SelectToSpeakService`，只伪装类名无效。
- 落地由 Config Plugin（`plugins/accessibility/app.plugin.js`）在 prebuild 时完成：把 8 个 kt 文件整体复制到 `com/google/android/accessibility/selecttospeak/` 目录，`package` 声明 rewrite 为该包，Manifest `android:name` 写成完整全限定名。三者必须严格一致，否则 `ClassNotFoundException`。
- 伪装生效后，`triggerManualExtraction` 成为微信账单页的主识别路径，必须用 `dumpAllTexts()`（覆盖所有窗口含 WebView 子窗口），不能用只读 `rootInActiveWindow` 的简化路径。
- OCR 截图兜底链路保留，作为伪装失败或极少数空白帧场景的保底。

---

## 1. 背景：微信 8.0.52+ 节点混淆

### 现象
微信账单详情页（我-服务-钱包-账单-某笔交易）上，点悬浮球「识别账单」后，`billingDebugNodes` 事件里的 `texts` 数组拿到的是乱码、错位文本或空数组。`parseWechatTexts`（`src/services/automation/accessibilityParser.ts`）依赖「标签 + 下一节点为值」的顺序假设，一旦文本被打乱就完全失效。

### 混淆原理
微信在生成无障碍节点时，用 Map 缓存了页面节点信息，混淆时**从 Map 中随机抽取其他节点的信息填入当前节点**。每次页面刷新混淆结果都不同，所以：
- 基于固定 `resource-id` 的 `findAccessibilityNodeInfosByViewId` 失效
- 基于文本匹配的 `findAccessibilityNodeInfosByText` 失效
- 基于顺序假设的扁平数组解析失效
- `className` 也被随机化（EditText/TextView 被换成动态生成类名）

### 白名单机制
微信维护了一份系统服务白名单，对白名单内的服务**不下发混淆**。TalkBack、SelectToSpeak 等系统级无障碍服务在白名单中。判断依据是服务的 `ComponentName`（即 `包名/类名`）。

> 这是整个绕过方案的支点：只要让微信认为我们的服务是系统 SelectToSpeak，就能拿到未混淆的真实节点。

---

## 2. 绕过方案：伪装 ComponentName

### 关键认知：只伪装类名不够
早期实现（本仓库 commit `bf04400` 之前）只把类名伪装成 `SelectToSpeakService`，但 Manifest 服务名写的是 `${appId}.accessibility.SelectToSpeakService`（应用包名 + SelectToSpeak 类名）。**包名没伪装**，所以微信仍将其识别为第三方服务，混淆照常下发。这是「伪装做了但没生效」的典型坑。

### 正确做法：包名 + 类名全限定伪装
微信按 `ComponentName` 全字符串匹配白名单，因此：

| 字段 | 伪装值 |
|---|---|
| Manifest `android:name` | `com.google.android.accessibility.selecttospeak.SelectToSpeakService` |
| Kotlin `package` | `com.google.android.accessibility.selecttospeak` |
| Kotlin `class` | `SelectToSpeakService` |
| 文件物理路径 | `app/src/main/java/com/google/android/accessibility/selecttospeak/SelectToSpeakService.kt` |

四者必须严格一致。Android 编译期要求 Manifest 全限定名必须有匹配 `package` + `class` 的真实 Kotlin 源码，否则 `ClassNotFoundException`。

### 为什么选 SelectToSpeak 而不是 TalkBack
早期业界方案伪装的是 `com.google.android.marvin.talkback.TalkBackService`，能成功绕过混淆，但**小米机型会误判 TalkBack 已开启**，屏幕持续显示「TalkBack 已激活」文字，严重干扰用户。SelectToSpeak（随选朗读）没有这个副作用，是更安全的选择。

---

## 3. Config Plugin 落地（`plugins/accessibility/app.plugin.js`）

伪装不是手动改生成的 `android/` 文件（那会被 `expo prebuild --clean` 冲掉），而是通过 Config Plugin 在 prebuild 时自动完成。

### 三个核心常量
```js
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
```

### 三个配合改动的环节

**① 文件复制路径**（`withDangerousMod`）
8 个 kt 文件统一复制到 `app/src/main/java/com/google/android/accessibility/selecttospeak/`，而不是应用包名目录。

**② package 重写**（正则替换）
kt 源码里仍写 `package com.beancount.mobile.accessibility`（源码锚点），Config Plugin 用正则替换为 FAKE_PACKAGE：
```js
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
```
⚠️ **关键坑**：正则必须精确匹配到 `.accessibility` 后缀。如果只匹配 `com.beancount.mobile`，替换结果会变成 `com.google.android.accessibility.selecttospeak.accessibility`（多出一段），与 Manifest 不一致 → 编译失败。详见 §5 坑 1。

**③ Manifest 服务名 + MainApplication import**（`withAndroidManifest` / `withMainApplication`）
- Manifest 服务 `android:name` 用 `FAKE_SERVICE_NAME` / `FAKE_TILE_SERVICE_NAME`
- MainApplication 里 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage`（注意 import 路径也要用 FAKE_PACKAGE，因为 BridgePackage 跟随其他文件一起伪装了）

### 「全部跟随伪装」 vs 「仅 Service 伪装」
本仓库 8 个 kt 文件（`SelectToSpeakService` / `AccessibilityBridgeModule` / `AccessibilityBridgePackage` / `FloatingHelper` / `FloatingBillView` / `FloatingTip` / `FloatingUiConfigStore` / `OcrTileService`）原本共享同一个 package，彼此通过同 package 直接引用（无显式 import）。

决策：**全部跟随伪装**到 FAKE_PACKAGE。优点是维持同 package 直接引用的简洁结构，零跨包 import 改动；代价是 `AccessibilityBridgeModule` / `OcrTileService` 这些不暴露给微信的组件也披上了系统服务包名（功能上无影响，它们靠 RN Bridge 和 QS Tile 识别，与微信无关）。

> 替代方案是只伪装 `SelectToSpeakService`，其余保持在应用包名，但要给 `FloatingHelper` / `OcrTileService` / `AccessibilityBridgeModule` 加跨包 import，改动点和潜在编译错误更多。

---

## 4. 数据流与触发路径

完整链路（手动识别路径，伪装生效后是主路径）：

```
[微信账单详情页前台]
  → onAccessibilityEvent (SelectToSpeakService.kt)
     → 记录 topPackage/topActivity + 显示悬浮球 (FloatingHelper)
[用户点悬浮球「识别账单」]
  → triggerManualExtraction() (SelectToSpeakService.kt)
     → dumpAllTexts()  ← 关键：覆盖所有窗口含 WebView 子窗口
        → rootInActiveWindow + dumpNodeTexts (递归 text + contentDescription)
        → 若为空，遍历 windows 兜底
     → emit "billingDebugNodes" {package, activity, signature, isManual:true, texts[]}
[JS 侧 _layout.tsx 监听 billingDebugNodes]
  → 仅处理 isManual=true 的消息
  → parseAndProcessAccessibilityTexts(texts, pkg) (automationPipeline.ts)
     → parseAccessibilityTexts → parseWechatTexts (accessibilityParser.ts)
        → 特征词「交易单号」/「退款单号」/「本服务由财付通提供」定位
        → 金额正则 ^([+-])?(\d+(\.\d{1,2})?)$ + 商户取金额前一节点
     → 命中 → handleIncomingBillEvent → 分类 + 规则匹配 → 生成 draft
        → App 前台：Alert 确认 / App 后台：showFloatingBill 原生浮窗
```

降级路径（兜底，伪装失败时）：
```
[文本为空或解析未命中] → bridge.triggerManualOcr()
  → takeScreenshot → bitmapToBase64 (JPEG q60) → emit "billingScreenshot"
[JS 侧] → OcrProcessor (Layer1 规则 → Layer2 ONNX OCR → Layer3 AI 视觉)
```

### 关键改造：`triggerManualExtraction` 必须用 `dumpAllTexts`
改造前 `triggerManualExtraction` 只读 `rootInActiveWindow`，漏掉 WebView 子窗口。伪装生效后这条路径成为主识别路径，必须保证完整性，所以改用现成的 `dumpAllTexts()`（先试 `rootInActiveWindow`，为空则遍历 `windows`）。

---

## 5. 踩过的坑

### 坑 1：package 重写正则不精确，多出 `.accessibility`
**现象**：首次 prebuild 后，kt 文件 package 声明变成 `com.google.android.accessibility.selecttospeak.accessibility`（多了 `.accessibility`），与 Manifest 的 `com.google.android.accessibility.selecttospeak.SelectToSpeakService` 不一致 → 编译期 `ClassNotFoundException`。

**根因**：源码 package 是 `com.beancount.mobile.accessibility`（有 `.accessibility` 后缀），但正则只写了 `com\.beancount\.mobile`，替换后保留了原有的 `.accessibility`。

**修复**：正则精确匹配到 `.accessibility`：
```js
// ❌ 错误：会留下 .accessibility 后缀
content.replace(/package\s+com\.beancount\.mobile/g, `package ${FAKE_PACKAGE}`)
// ✅ 正确：整体替换
content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`)
```

**验证方法**：prebuild 后用 `head -1` 检查生成的 kt 文件 package 声明，与 Manifest `android:name` 的包名部分逐字对比。

### 坑 2：只伪装类名不伪装包名
早期实现 Manifest 服务名是 `${appId}.accessibility.SelectToSpeakService`，类名伪装了但包名是应用包名。微信按完整 `ComponentName` 判断，包名不在白名单 → 混淆照常下发。详见 §2。

### 坑 3：升级后服务标识变化，用户需重新启用
伪装改变服务的 `ComponentName`，系统无障碍设置里的服务标识随之变化。升级 APK 后，用户原本启用的服务会「失效」（实际是新服务没启用），需要重新在系统设置里启用「浮记-账单识别」。服务 `label` 保持 `浮记-账单识别` 不变（在 Manifest 硬编码），用户可识别。

> 这是预期行为，发布说明里需告知。

---

## 6. 验证方法论

代码改动后，按以下顺序验证（沙箱内能做的 vs 真机才能做的分开）：

### 沙箱内（prebuild 后静态检查）
1. **kt 文件路径**：`find android/app/src/main/java/com/google/android/accessibility/selecttospeak -type f` 应列出 8 个 kt 文件，且**不应有**应用包名路径下的遗留 kt。
2. **package 声明一致性**：`head -1` 每个 kt 文件，都应是 `package com.google.android.accessibility.selecttospeak`。
3. **Manifest 服务名**：`grep "android:name=\"com.google.android.accessibility" android/app/src/main/AndroidManifest.xml` 应同时命中 `SelectToSpeakService` 和 `OcrTileService`，且包名部分与 kt 的 package 声明逐字一致。
4. **MainApplication import**：`grep "AccessibilityBridgePackage" android/app/src/main/java/*/MainApplication.kt` 应有 `import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage` 和 `add(AccessibilityBridgePackage())`。
5. **typecheck + 测试**：`npm run typecheck` 通过；`npm test` 无新增回归（无障碍改动不涉及 ts，测试应零影响）。

### 真机（功能验证）
1. `npm run android` 构建 APK 安装。
2. 系统设置 → 无障碍 → 启用「浮记-账单识别」。
3. 打开微信（≥ 8.0.52）→ 账单详情页。
4. 点悬浮球「识别账单」。
5. 看 Metro 终端的 `billingDebugNodes` 事件：
   - **成功**：`texts` 数组含真实金额/商户/时间 → `parseWechatTexts` 自动解析入账。
   - **失败**：`texts` 仍为空或乱码 → 自动降级 OCR 截图（兜底链路未动）。
6. 若伪装完全无效（texts 始终空），说明微信检测点不止 ComponentName（可能还查签名/其他特征），需进一步研究；此时 OCR 兜底仍在工作，不影响基础功能。

---

## 7. 合规与风险

- **侧载路线**：本仓库按 `plan.md` 决策 3 走纯开源侧载，伪装机制仅用于非应用商店分发。伪装系统服务包名、绕过微信反自动化检测，可能违反微信用户协议，存在账号风险。
- **TalkBack 副作用**：不要回退到伪装 TalkBack 的方案，小米机型会有持续文字干扰（§2）。
- **版本适配**：微信持续更新混淆策略。若某次微信升级后伪装失效，先查 AutoJs6 Issue、CSDN/掘金的最新讨论，确认白名单判断逻辑是否变化。
- **回退**：伪装失败时，OCR 截图兜底链路（`_layout.tsx` 的 `billingScreenshot` 监听 + `OcrProcessor`）完整保留，最小代价回退。

---

## 8. 关键文件清单

| 文件 | 作用 |
|---|---|
| `plugins/accessibility/app.plugin.js` | Config Plugin：文件复制、package rewrite、Manifest 注册、MainApplication import |
| `plugins/accessibility/android/SelectToSpeakService.kt` | 主服务：事件监听、`dumpAllTexts`/`dumpNodeTexts` 节点遍历、截图、悬浮球管理 |
| `plugins/accessibility/android/AccessibilityBridgeModule.kt` | RN Bridge：14 个 `@ReactMethod`，委托 `SelectToSpeakService.instance` |
| `plugins/accessibility/android/FloatingHelper.kt` | 悬浮球 UI，点「识别账单」调 `triggerManualExtraction` |
| `plugins/accessibility/android/FloatingBillView.kt` | 账单确认浮窗 |
| `plugins/accessibility/android/OcrTileService.kt` | 快速设置磁贴 |
| `plugins/accessibility/android/res/xml/accessibility_service_config.xml` | 服务能力声明（`canRetrieveWindowContent` / `canTakeScreenshot` / `canRequestEnhancedWebAccessibility`） |
| `src/services/automation/accessibilityParser.ts` | 微信/支付宝/银行文本解析（`parseWechatTexts` 等） |
| `src/services/automation/automationPipeline.ts` | 管道协调器（`parseAndProcessAccessibilityTexts`） |
| `src/app/_layout.tsx` | `DeviceEventEmitter` 监听 `billingDebugNodes` / `billingScreenshot` |

---

## 9. 后续优化方向（未实施）

- **方案 C：`parseWechatTexts` 鲁棒性增强**。即使伪装生效，微信仍可能对部分节点做轻度扰动。可放弃「标签 + 下一节点」的顺序假设，改用 `getBoundsInScreen()` 的坐标和相对位置识别元素，或用 index 在层级中的位置而非属性。工作量大，建议伪装方案稳定后再立项。
- **自动监听路径恢复**。当前因节点混淆，自动监听（`processContentChange`）实质失效，仅手动悬浮球路径有效。伪装生效后可评估是否恢复自动监听，但需注意 `isManual` 标志和页面签名白名单的配合。
