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:
fengmengqi
2026-07-21 15:16:44 +08:00
parent 76a5853ab6
commit 7fa345b558
119 changed files with 3348 additions and 1302 deletions
@@ -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,