### 架构重构:三层分层目录化 - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
212 lines
15 KiB
Markdown
212 lines
15 KiB
Markdown
# 无障碍服务与微信文本抓取指南 (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` 标志和页面签名白名单的配合。
|