DriftLedger/plugins/accessibility/app.plugin.js
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

189 lines
7.1 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 配置)。
* 参考 AutoAccounting 的 SelectToSpeakService 伪装机制侧载保留plan.md 决策 3
*
* 注意withAndroidManifest 的 modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application。
*/
const { withAndroidManifest, withDangerousMod, withMainApplication } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const PACKAGE = 'com.beancount.mobile.accessibility';
/** 递归复制目录。 */
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) {
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
const ktDest = path.join(projectRoot, 'app/src/main/java/com/beancount/mobile/accessibility');
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
fs.copyFileSync(path.join(androidDir, f), path.join(ktDest, f));
}
}
// res/xml 资源
const resSrc = path.join(__dirname, 'android/res');
if (fs.existsSync(resSrc)) {
copyDir(resSrc, path.join(projectRoot, 'app/src/main/res'));
}
// 确保有 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. 注入 ReactContextHolder 赋值 + 注册 AccessibilityBridgePackage 到 MainApplication
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 importReactContextHolder + AccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.ReactContextHolder`)) {
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`,
);
} else if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
content = content.replace(
`import ${PACKAGE}.ReactContextHolder`,
`import ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`,
);
}
// 2b. 在 onCreate 方法中注入 ReactContextHolder.context = this
if (!content.includes('ReactContextHolder.context')) {
// 尝试在 super.onCreate() 后注入
if (/super\.onCreate\(\)/.test(content)) {
content = content.replace(
/(super\.onCreate\(\))/,
`$1\n // 由 Config Plugin 注入:让原生服务能访问 RN 上下文\n ReactContextHolder.context = this`,
);
} else if (/onCreate/.test(content)) {
// 有 onCreate 但没有 super.onCreate()(不太可能)
content = content.replace(
/(onCreate[^{]*\{)/,
`$1\n ReactContextHolder.context = this`,
);
} else {
// 没有 onCreate — 在类体内注入一个
content = content.replace(
/(class\s+MainApplication\s*[^{]*\{)/,
`$1\n override fun onCreate() {\n super.onCreate()\n ReactContextHolder.context = this\n }`,
);
}
}
// 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;
// 1. 添加无障碍服务声明
const serviceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.BillingAccessibilityService',
'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'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService'
);
if (!exists) {
app.service.push(serviceNode);
}
// 3. 添加 OcrTileService 声明快速设置磁贴plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': 'com.beancount.mobile.accessibility.OcrTileService',
'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'] === 'com.beancount.mobile.accessibility.OcrTileService'
);
if (!tileExists) {
app.service.push(tileServiceNode);
}
return modConfig;
});
return config;
}
module.exports = withAccessibilityService;