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

15 KiB
Raw Blame History

无障碍服务与微信文本抓取指南 (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 Pluginplugins/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 数组拿到的是乱码、错位文本或空数组。parseWechatTextssrc/services/automation/accessibilityParser.ts)依赖「标签 + 下一节点为值」的顺序假设,一旦文本被打乱就完全失效。

混淆原理

微信在生成无障碍节点时,用 Map 缓存了页面节点信息,混淆时从 Map 中随机抽取其他节点的信息填入当前节点。每次页面刷新混淆结果都不同,所以:

  • 基于固定 resource-idfindAccessibilityNodeInfosByViewId 失效
  • 基于文本匹配的 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 importwithAndroidManifest / withMainApplication

  • Manifest 服务 android:nameFAKE_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

// ❌ 错误:会留下 .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 应同时命中 SelectToSpeakServiceOcrTileService,且包名部分与 kt 的 package 声明逐字一致。
  4. MainApplication importgrep "AccessibilityBridgePackage" android/app/src/main/java/*/MainApplication.kt 应有 import com.google.android.accessibility.selecttospeak.AccessibilityBridgePackageadd(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.tsxbillingScreenshot 监听 + 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. 后续优化方向(未实施)

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