DriftLedger/plugins/accessibility/app.plugin.js
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

241 lines
9.9 KiB
JavaScript
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.

/**
* 无障碍服务 Config Pluginplan.md「3.6 无障碍服务」+「决策 4」
*
* 在 expo prebuild 时注册 Android 无障碍服务manifest service + xml 配置)。
*
* 伪装机制(绕过微信 8.0.52+ 节点混淆):
* 微信按 ComponentName包名/类名)识别系统服务白名单。早期只伪装类名不伪装包名,
* 因此被微信识别为第三方服务、对节点 text/contentDescription 做混淆。
* 现在把 7 个 kt 文件整体移到 com.google.android.accessibility.selecttospeak 包,
* 并把 Manifest 服务名写成完整全限定名,让微信将其识别为系统 SelectToSpeak 服务,
* 从而拿到未混淆的真实节点文本。
*
* 注意withAndroidManifest 的 modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application。
*/
const { withAndroidManifest, withDangerousMod, withMainApplication } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* 伪装目标包名:系统 SelectToSpeak 服务的完整包名。
* 7 个 kt 文件会被复制到此包路径下Manifest 服务名也用此包的全限定名。
*/
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
/** SelectToSpeakService 的完整 ComponentNameManifest android:name 用)。 */
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
/** OcrTileService 的完整 ComponentName与 Service 同包,保持同 package 引用)。 */
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
/**
* 从 src/domain/constants.ts 的 PAYMENT_PACKAGES 中提取包名列表。
* 这是唯一的真相来源Kotlin 端通过此函数自动同步。
*/
function readPaymentPackagesFromConstants() {
const constantsPath = path.resolve(__dirname, '../../src/domain/constants.ts');
if (!fs.existsSync(constantsPath)) return null;
const content = fs.readFileSync(constantsPath, 'utf8');
// 匹配 PAYMENT_PACKAGES = { ... } 中的 key冒号前的字符串
const match = content.match(/PAYMENT_PACKAGES:\s*Record<string,\s*string>\s*=\s*\{([\s\S]*?)\n\};/);
if (!match) return null;
const keys = [];
const keyRegex = /^(\s*)'([^']+)'\s*:/gm;
let m;
while ((m = keyRegex.exec(match[1])) !== null) {
keys.push(m[2]);
}
return keys.length > 0 ? keys : null;
}
/**
* 生成 Kotlin setOf("...", "...", ...) 代码。
*/
function generateKotlinPaymentPackages(packages) {
const items = packages.map(pkg => ` "${pkg}"`).join(',\n');
return ` val PAYMENT_PACKAGES = setOf(\n${items}\n )`;
}
/** 递归复制目录。 */
function copyDir(src, dest) {
if (!fs.existsSync(src)) return;
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src)) {
const s = path.join(src, entry);
const d = path.join(dest, entry);
if (fs.statSync(s).isDirectory()) copyDir(s, d);
else fs.copyFileSync(s, d);
}
}
function withAccessibilityService(config) {
// 7 个 kt 文件全部跟随 SelectToSpeakService 伪装到 FAKE_PACKAGE 包下,
// 不再使用应用真实包名appId
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码统一复制到 FAKE_PACKAGE 目录下(伪装为系统 SelectToSpeak 服务包名)。
// kt 源码里仍写 `com.beancount.mobile`,此处用正则替换为 FAKE_PACKAGE。
const fakePkgPath = FAKE_PACKAGE.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', fakePkgPath);
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
// kt 源码 package 为 com.beancount.mobile.accessibility整体替换为 FAKE_PACKAGE。
// 注意正则要匹配到 .accessibility 后缀,否则会多出 .accessibility 导致与 Manifest 不一致。
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
content = content.replace(/import\s+com\.beancount\.mobile\.accessibility/g, `import ${FAKE_PACKAGE}`);
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
}
}
// res/xml 资源
const resSrc = path.join(__dirname, 'android/res');
if (fs.existsSync(resSrc)) {
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res'));
}
// 从 constants.ts 读取包名列表,自动同步到 Kotlin
const packages = readPaymentPackagesFromConstants();
if (packages) {
const ktFile = path.join(ktDest, 'SelectToSpeakService.kt');
if (fs.existsSync(ktFile)) {
let ktContent = fs.readFileSync(ktFile, 'utf8');
// 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... )
const regex = /val PAYMENT_PACKAGES = setOf\([\s\S]*?\)/;
if (regex.test(ktContent)) {
ktContent = ktContent.replace(regex, generateKotlinPaymentPackages(packages));
fs.writeFileSync(ktFile, ktContent, 'utf8');
console.log(`[accessibility plugin] 已同步 ${packages.length} 个支付 App 包名到 Kotlin`);
}
}
}
// 确保有 accessibility_service_description 字符串资源
const stringsXmlPath = path.join(projectRoot, 'app/src/main/res/values/strings.xml');
if (fs.existsSync(stringsXmlPath)) {
let content = fs.readFileSync(stringsXmlPath, 'utf8');
if (!content.includes('accessibility_service_description')) {
content = content.replace(
/<\/resources>/,
' <string name="accessibility_service_description">自动识别支付账单页面,辅助快速记账</string>\n</resources>',
);
fs.writeFileSync(stringsXmlPath, content, 'utf8');
}
}
return modConfig;
},
]);
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
// 注意AccessibilityBridgePackage 跟随其他 kt 文件一起被复制到 FAKE_PACKAGE 下,
// 因此 import 路径要用 FAKE_PACKAGE 而非 PACKAGE应用包名
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 importAccessibilityBridgePackage
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${FAKE_PACKAGE}.AccessibilityBridgePackage`,
);
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
if (!content.includes('add(AccessibilityBridgePackage())')) {
if (/PackageList\(this\)\.packages\.apply\s*\{/.test(content)) {
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n add(AccessibilityBridgePackage())`,
);
} else if (/PackageList\(this\)\.packages\b/.test(content)) {
content = content.replace(
/PackageList\(this\)\.packages\b/,
`PackageList(this).packages.apply { add(AccessibilityBridgePackage()) }`,
);
}
}
modConfig.modResults.contents = content;
return modConfig;
});
// 3. 注册权限 + 服务到 AndroidManifest
config = withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
// 0. 添加 SYSTEM_ALERT_WINDOW 权限(悬浮窗必需)
if (!manifest['uses-permission']) {
manifest['uses-permission'] = [];
}
const overlayPerm = 'android.permission.SYSTEM_ALERT_WINDOW';
const overlayPermExists = manifest['uses-permission'].some(p => p.$['android:name'] === overlayPerm);
if (!overlayPermExists) {
manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } });
}
// 1. 添加无障碍服务声明(服务名使用伪装包的全限定名,以匹配微信系统服务白名单)
const serviceNode = {
$: {
'android:name': FAKE_SERVICE_NAME,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '浮记-账单识别',
'android:exported': 'false',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.accessibilityservice.AccessibilityService' } }],
}],
'meta-data': [{
$: {
'android:name': 'android.accessibilityservice',
'android:resource': '@xml/accessibility_service_config',
},
}],
};
// 2. 确保 application[0] 存在
if (!Array.isArray(manifest.application) || manifest.application.length === 0) {
manifest.application = [{ $: {} }];
}
const app = manifest.application[0];
if (!app.service) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === FAKE_SERVICE_NAME
);
if (!exists) {
app.service.push(serviceNode);
}
// 3. 添加 OcrTileService 声明快速设置磁贴plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': FAKE_TILE_SERVICE_NAME,
'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
'android:exported': 'true',
},
'intent-filter': [{
action: [{ $: { 'android:name': 'android.service.quicksettings.action.QS_TILE' } }],
}],
};
const tileExists = app.service.some(
s => s.$['android:name'] === FAKE_TILE_SERVICE_NAME
);
if (!tileExists) {
app.service.push(tileServiceNode);
}
return modConfig;
});
return config;
}
module.exports = withAccessibilityService;