feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构
品牌与配置 - 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger - 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile - 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端 - size-optimization 插件新增 NDK 版本强制统一(27.1.12297006) - 添加 app icon(direction_a_feather.png) Decimal 精度改造 - 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算 - 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD) - negateDecimal 零值特殊处理,避免产生 "-0" - 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移 - 去重指纹用 parseDecimal 归一化金额格式 - 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00) 架构变更 - mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean - TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方) - 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑 - 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用 - 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析 - 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建 - 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU) - ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析 - metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码 安全与持久化 - PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移 - storePersistence 从 persistTo 回调改为 subscribe 自动触发 - 持久化增加深比较跳过无变化写入 + 滞后重试 - 新增 flushPersistence(),App 切后台时立即刷盘 - CrashReporter 新增文件持久化(CrashFs 抽象) - settingsStore hydrate 期间跳过自动持久化(skipPersist 标志) 去重与管道 - batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1) - dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选 - OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求 - 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰 - 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API 其他改进 - recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日) - 预算进度按 posting 分录金额求和(不再仅过滤负数金额) - recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移 - 信用卡账单日/还款日限制不超过28,防止月份溢出 - 类别模糊匹配增加最小长度2,避免单字符误匹配 - 还款检测排除 refund 方向,避免退款被误判为还款 - WebDAV 同步增加 HTTP 日期格式解析 - Git 合并策略改为以 remote 为基础追加 local 独有行 性能优化 - commonStyles 全局 useMemo 缓存 - Button/Card 的 pressIn/pressOut 改用 useCallback - TransactionCard/AccountTreeNode 包裹 React.memo - PostingEditor 用 refs + 函数式更新避免重建 - CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历 - OCR 处理器单例复用,配置变更时才重建 - getLocalAuth/getAccessibilityBridge 结果缓存 测试 - 新增 decimal/constants/channelConfig/transactionBuilder 单测 - 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为 - billPipeline 新增不同格式金额配对、指纹唯一性测试
This commit is contained in:
@@ -230,4 +230,21 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置悬浮球开关状态。 */
|
||||
@ReactMethod
|
||||
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
|
||||
BillingAccessibilityService.floatingBallEnabled = enabled
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
}
|
||||
if (!enabled) {
|
||||
service.dismissFloatingHelper()
|
||||
} else {
|
||||
service.refreshFloatingHelper()
|
||||
}
|
||||
promise.resolve(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,14 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
var instance: BillingAccessibilityService? = null
|
||||
private set
|
||||
|
||||
/** 支付 App 白名单。 */
|
||||
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
|
||||
@Volatile
|
||||
var floatingBallEnabled = true
|
||||
|
||||
/**
|
||||
* 支付 App 包名(自动生成,来源:src/domain/constants.ts PAYMENT_PACKAGES)。
|
||||
* 修改时只需编辑 constants.ts,运行 expo prebuild 即可同步。
|
||||
*/
|
||||
val PAYMENT_PACKAGES = setOf(
|
||||
"com.eg.android.AlipayGphone", // 支付宝
|
||||
"com.tencent.mm", // 微信
|
||||
@@ -79,6 +86,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
private var ocrDoing = false
|
||||
private var ocrDoingTimestamp = 0L
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val debounceRunnable = Runnable { processContentChange() }
|
||||
@Volatile private var topPackage: String? = null
|
||||
@@ -93,6 +101,14 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
Log.i(TAG, "无障碍账单识别服务已连接")
|
||||
loadPageSignatures()
|
||||
configureService()
|
||||
// 修复:检查当前前台 App,恢复悬浮球
|
||||
val root = rootInActiveWindow
|
||||
val pkg = root?.packageName?.toString()
|
||||
root?.recycle()
|
||||
if (pkg != null) {
|
||||
topPackage = pkg
|
||||
updateFloatingHelperVisibility(pkg)
|
||||
}
|
||||
}
|
||||
|
||||
/** 动态配置服务能力(截图 + 页面变化监听)。 */
|
||||
@@ -110,7 +126,15 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (ocrDoing) return // 处理中,跳过
|
||||
// ocrDoing 超时兜底:5秒后强制重置
|
||||
if (ocrDoing) {
|
||||
if (System.currentTimeMillis() - ocrDoingTimestamp > 5000) {
|
||||
Log.w(TAG, "ocrDoing 超时重置")
|
||||
ocrDoing = false
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
val eventPackage = event?.packageName?.toString() ?: return
|
||||
|
||||
when (event.eventType) {
|
||||
@@ -120,13 +144,12 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
topPackage = eventPackage
|
||||
topActivity = activityName
|
||||
Log.d(TAG, "页面切换: $eventPackage / $activityName")
|
||||
|
||||
|
||||
// 更新悬浮窗助手状态
|
||||
updateFloatingHelperVisibility(eventPackage)
|
||||
|
||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
||||
scheduleContentChange()
|
||||
}
|
||||
// 页面切换时检查是否有已记住的签名需要触发文本提取
|
||||
scheduleContentChange()
|
||||
|
||||
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
|
||||
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
|
||||
@@ -159,16 +182,20 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
||||
scheduleContentChange()
|
||||
}
|
||||
// 内容变化时也检查(防抖合并)
|
||||
scheduleContentChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFloatingHelperVisibility(pkg: String?) {
|
||||
handler.post {
|
||||
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) {
|
||||
val shouldShow = floatingBallEnabled
|
||||
&& pkg != null
|
||||
&& !filterPackage(pkg, "")
|
||||
&& !isLandscape()
|
||||
|
||||
if (shouldShow) {
|
||||
if (floatingHelper == null) {
|
||||
floatingHelper = FloatingHelper(this)
|
||||
floatingHelper?.show()
|
||||
@@ -236,6 +263,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
private fun takeScreenshotAndProcess(packageName: String) {
|
||||
if (ocrDoing) return
|
||||
ocrDoing = true
|
||||
ocrDoingTimestamp = System.currentTimeMillis()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
ocrDoing = false
|
||||
return
|
||||
@@ -451,6 +479,21 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭悬浮球(由 JS 层 setFloatingBallEnabled(false) 调用)。 */
|
||||
fun dismissFloatingHelper() {
|
||||
handler.post {
|
||||
floatingHelper?.dismiss()
|
||||
floatingHelper = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新悬浮球状态(由 JS 层 setFloatingBallEnabled(true) 调用)。 */
|
||||
fun refreshFloatingHelper() {
|
||||
handler.post {
|
||||
updateFloatingHelperVisibility(topPackage)
|
||||
}
|
||||
}
|
||||
|
||||
/** 横屏检测(plan.md「3.10 横屏免打扰」)。 */
|
||||
private fun isLandscape(): Boolean {
|
||||
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
@@ -459,17 +502,17 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
|
||||
}
|
||||
|
||||
/** 过滤系统组件/桌面(不触发 OCR)。 */
|
||||
/** 过滤不应处理的系统组件(桌面启动器、SystemUI、输入法等)。 */
|
||||
private fun filterPackage(pkg: String, className: String): Boolean {
|
||||
val p = pkg.lowercase()
|
||||
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗;
|
||||
// 其他自身组件(如悬浮球容器 LinearLayout)一律过滤,避免自毁式关闭。
|
||||
// 自身 App:只放行 MainActivity,避免悬浮球容器触发自毁式关闭
|
||||
if (pkg == packageName) {
|
||||
return className != "com.example.beanmobile.MainActivity"
|
||||
return !className.endsWith(".MainActivity")
|
||||
}
|
||||
if (p == "android" || p.startsWith("com.android.") || p.startsWith("com.google.android.")) return true
|
||||
if (p.contains("systemui") || p.contains("settings") || p.contains("inputmethod") || p.contains("keyboard") || p.contains("input")) return true
|
||||
// 桌面启动器(悬浮球在桌面无意义)
|
||||
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
|
||||
// SystemUI / 输入法(悬浮球在这些组件上会遮挡系统 UI)
|
||||
if (p.contains("systemui") || p.contains("inputmethod") || p.contains("keyboard")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ class FloatingHelper(
|
||||
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
|
||||
}
|
||||
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
||||
gravity = Gravity.CENTER
|
||||
gravity = Gravity.CENTER_VERTICAL or Gravity.START // 贴左边缘
|
||||
}
|
||||
}
|
||||
bubbleLayout.addView(indicatorView)
|
||||
@@ -255,11 +255,11 @@ class FloatingHelper(
|
||||
if (!isMoving) {
|
||||
toggleMenu()
|
||||
} else {
|
||||
// 拖动抬起时自动吸附到屏幕边缘
|
||||
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
|
||||
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
|
||||
params.x = 0
|
||||
params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘
|
||||
} else {
|
||||
params.x = service.resources.displayMetrics.widthPixels - dp(24f)
|
||||
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
|
||||
}
|
||||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||||
|
||||
@@ -276,6 +276,10 @@ class FloatingHelper(
|
||||
Log.i(TAG, "记账助手悬浮窗显示成功")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
|
||||
// 修复:失败时清理状态,允许下次重试
|
||||
containerView = null
|
||||
bubbleView = null
|
||||
menuView = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ class OcrTileService : TileService() {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent().apply {
|
||||
setClassName(packageName, "com.beancount.mobile.MainActivity")
|
||||
action = "com.beancount.mobile.TRIGGER_OCR"
|
||||
setClassName(packageName, "$packageName.MainActivity")
|
||||
action = "$packageName.TRIGGER_OCR"
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
|
||||
@@ -12,7 +12,33 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PACKAGE = 'com.beancount.mobile.accessibility';
|
||||
/**
|
||||
* 从 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) {
|
||||
@@ -26,19 +52,30 @@ function copyDir(src, dest) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAppId(config) {
|
||||
return config.android?.package || 'com.example.driftledger';
|
||||
}
|
||||
|
||||
function withAccessibilityService(config) {
|
||||
const appId = getAppId(config);
|
||||
const PACKAGE = `${appId}.accessibility`;
|
||||
|
||||
// 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');
|
||||
const pkgPath = appId.replace(/\./g, '/');
|
||||
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, '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));
|
||||
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
|
||||
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
|
||||
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
|
||||
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
|
||||
}
|
||||
}
|
||||
// res/xml 资源
|
||||
@@ -46,6 +83,23 @@ function withAccessibilityService(config) {
|
||||
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, 'BillingAccessibilityService.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)) {
|
||||
@@ -93,14 +147,24 @@ function withAccessibilityService(config) {
|
||||
return modConfig;
|
||||
});
|
||||
|
||||
// 3. 注册服务到 AndroidManifest
|
||||
// 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': 'com.beancount.mobile.accessibility.BillingAccessibilityService',
|
||||
'android:name': `${PACKAGE}.BillingAccessibilityService`,
|
||||
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
|
||||
'android:label': '账单识别',
|
||||
'android:exported': 'false',
|
||||
@@ -125,7 +189,7 @@ function withAccessibilityService(config) {
|
||||
app.service = [];
|
||||
}
|
||||
const exists = app.service.some(
|
||||
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.BillingAccessibilityService'
|
||||
s => s.$['android:name'] === `${PACKAGE}.BillingAccessibilityService`
|
||||
);
|
||||
if (!exists) {
|
||||
app.service.push(serviceNode);
|
||||
@@ -134,7 +198,7 @@ function withAccessibilityService(config) {
|
||||
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
|
||||
const tileServiceNode = {
|
||||
$: {
|
||||
'android:name': 'com.beancount.mobile.accessibility.OcrTileService',
|
||||
'android:name': `${PACKAGE}.OcrTileService`,
|
||||
'android:label': 'OCR 记账',
|
||||
'android:icon': '@android:drawable/ic_menu_camera',
|
||||
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
|
||||
@@ -145,7 +209,7 @@ function withAccessibilityService(config) {
|
||||
}],
|
||||
};
|
||||
const tileExists = app.service.some(
|
||||
s => s.$['android:name'] === 'com.beancount.mobile.accessibility.OcrTileService'
|
||||
s => s.$['android:name'] === `${PACKAGE}.OcrTileService`
|
||||
);
|
||||
if (!tileExists) {
|
||||
app.service.push(tileServiceNode);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "beancount-mobile-plugin-accessibility",
|
||||
"name": "drift-ledger-plugin-accessibility",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "app.plugin.js"
|
||||
|
||||
Reference in New Issue
Block a user