### 架构重构:三层分层目录化 - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
15 KiB
无障碍服务与微信文本抓取指南 (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 为该包,Manifestandroid: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 时自动完成。
三个核心常量
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:
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:
// ❌ 错误:会留下 .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 后静态检查)
- kt 文件路径:
find android/app/src/main/java/com/google/android/accessibility/selecttospeak -type f应列出 8 个 kt 文件,且不应有应用包名路径下的遗留 kt。 - package 声明一致性:
head -1每个 kt 文件,都应是package com.google.android.accessibility.selecttospeak。 - Manifest 服务名:
grep "android:name=\"com.google.android.accessibility" android/app/src/main/AndroidManifest.xml应同时命中SelectToSpeakService和OcrTileService,且包名部分与 kt 的 package 声明逐字一致。 - MainApplication import:
grep "AccessibilityBridgePackage" android/app/src/main/java/*/MainApplication.kt应有import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackage和add(AccessibilityBridgePackage())。 - typecheck + 测试:
npm run typecheck通过;npm test无新增回归(无障碍改动不涉及 ts,测试应零影响)。
真机(功能验证)
npm run android构建 APK 安装。- 系统设置 → 无障碍 → 启用「浮记-账单识别」。
- 打开微信(≥ 8.0.52)→ 账单详情页。
- 点悬浮球「识别账单」。
- 看 Metro 终端的
billingDebugNodes事件:- 成功:
texts数组含真实金额/商户/时间 →parseWechatTexts自动解析入账。 - 失败:
texts仍为空或乱码 → 自动降级 OCR 截图(兜底链路未动)。
- 成功:
- 若伪装完全无效(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标志和页面签名白名单的配合。