DriftLedger/docs/accessibility-wechat-guide.md
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform)
  - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction)
  - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取
  - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦

  ### 微信账单抓取:节点混淆绕过(核心突破)
  - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务
  - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验
  - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动
  - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错)
  - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录)

  ### 新 UI 组件体系
  - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失)
  - ErrorBoundary:React class 错误边界(降级 UI + 重试)
  - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker
  - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让
  - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField)
  - 新增 PeriodSwitcher、RangeStatsCard 独立组件

  ### 新 Hooks & 工具
  - useBottomInset — 统一底部安全区留白
  - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案)
  - sanitize.ts — 日志脱敏工具提取

  ### 账本增删改增强
  - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set
  - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean)
  - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」
  - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误)
  - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致

  ### OCR 原生模块增强
  - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」
  - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload
  - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别)
  - 图片缩放策略重命名(scaleDownForOcr → capLongEdge)

  ### OCR 模型按需下载
  - 移除了启动时自动下载 ~30MB OCR 模型的逻辑
  - 改为首次使用 OCR 时才触发下载

  ### 设置页重设计
  - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头)
  - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片

  ### 首页优化
  - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条)
  - 日期/金额格式化增加 locale 感知(zh/en)

  ### 通知管道增强
  - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除
  - 增加 debug 日志输出(过滤原因/包名)

  ### ESLint
  - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集)
  - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies

  ### 文档
  - accessibility-wechat-guide.md — 微信无障碍伪装完整方案
  - modal-keyboard-guide.md — 弹窗键盘避让方案
  - ocr-pipeline-guide.md — OCR 三层层级管线
  - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

212 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 无障碍服务与微信文本抓取指南 (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. 踩过的坑
### 坑 1package 重写正则不精确,多出 `.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 Bridge14 个 `@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` 标志和页面签名白名单的配合。