DriftLedger/plugins/accessibility/android/FloatingHelper.kt
fengmengqi 7fa345b558 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 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00

423 lines
16 KiB
Kotlin

package com.beancount.mobile.accessibility
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PixelFormat
import android.graphics.RectF
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
/**
* 记账助手悬浮球(边缘竖线胶囊面板)。
* 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。
*/
class FloatingHelper(
private val service: BillingAccessibilityService
) {
companion object {
private const val TAG = "FloatingHelper"
private var lastX = 0
private var lastY = 400
}
private val windowManager = service.getSystemService(Context.WINDOW_SERVICE) as WindowManager
private var containerView: LinearLayout? = null
private var bubbleView: View? = null
private var menuView: LinearLayout? = null
private var isExpanded = false
private val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
x = lastX
y = lastY
}
@SuppressLint("ClickableViewAccessibility")
fun show() {
if (containerView != null) return
try {
val context = service
val density = service.resources.displayMetrics.density
fun dp(value: Float) = (value * density).toInt()
// 1. 创建整体包裹容器 (水平排列,当贴在左侧时,菜单向右展开)
containerView = LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setOnTouchListener { _, event ->
if (event.action == MotionEvent.ACTION_OUTSIDE) {
if (isExpanded) {
collapse()
}
}
false
}
}
// 2. 创建高透明度悬浮球/边缘竖线 (仅 3dp 宽的极简胶囊,搭配 24dp 的宽触控区)
val bubbleLayout = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(24f), dp(60f))
}
val indicatorView = View(context).apply {
background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(1.5f).toFloat() // 高度圆润
setColor(0xB05E6AD2.toInt()) // 70% 高透靛蓝色,无边框
}
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
gravity = Gravity.CENTER_VERTICAL or Gravity.START // 贴左边缘
}
}
bubbleLayout.addView(indicatorView)
bubbleView = bubbleLayout
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
val menuBg = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = dp(10f).toFloat()
setColor(0xA611131E.toInt()) // 65% OLED high-transparency black
setStroke(dp(0.8f), 0x22FFFFFF.toInt()) // subtle 13% white border
}
menuView = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
background = menuBg
setPadding(dp(4f), dp(4f), dp(4f), dp(4f))
visibility = View.GONE
layoutParams = LinearLayout.LayoutParams(
dp(96f), // ultra-compact width: 96dp
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = dp(4f) // spacing with indicator line
}
}
// Vector icons
val sizePx = dp(14f)
val strokePx = dp(1.4f).toFloat()
val ocrIcon = ScanIconDrawable(0xFF818CF8.toInt(), strokePx, 0xFFF87171.toInt(), sizePx)
val pinIcon = PinIconDrawable(0xFF34D399.toInt(), strokePx, sizePx)
// Button 1: "识别账单"
val btnOcr = TextView(context).apply {
text = "识别账单"
setTextColor(0xFFFFFFFF.toInt())
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt()) // translucent indigo background
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(32f)
).apply {
bottomMargin = dp(5f)
}
setCompoundDrawablesWithIntrinsicBounds(ocrIcon, null, null, null)
compoundDrawablePadding = dp(4f)
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x405E6AD2.toInt())
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x1F5E6AD2.toInt())
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
}
}
}
true
}
setOnClickListener {
service.triggerManualExtraction()
}
}
// Button 2: "记住此页"
val btnRemember = TextView(context).apply {
text = "记住此页"
setTextColor(0xFFE5E7EB.toInt())
textSize = 11f
paint.isFakeBoldText = true
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(6f), 0, dp(6f), 0)
background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt()) // subtle translucent gray
}
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(32f)
)
setCompoundDrawablesWithIntrinsicBounds(pinIcon, null, null, null)
compoundDrawablePadding = dp(4f)
setOnTouchListener { view, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x30FFFFFF.toInt())
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
view.background = GradientDrawable().apply {
cornerRadius = dp(7f).toFloat()
setColor(0x15FFFFFF.toInt())
}
if (event.action == MotionEvent.ACTION_UP) {
view.performClick()
}
}
}
true
}
setOnClickListener {
try {
service.rememberCurrentPage()
FloatingTip(service, "📌 已将当前页面加入识别白名单!", FloatingTip.TipPosition.TOP, 2500L).show()
} catch (e: Exception) {
FloatingTip(service, "记录失败: ${e.message}", FloatingTip.TipPosition.TOP, 2500L).show()
}
collapse()
}
}
menuView?.addView(btnOcr)
menuView?.addView(btnRemember)
containerView?.addView(bubbleView)
containerView?.addView(menuView)
// 4. 设置悬浮条拖动事件 (点击即展开,拖动则调整位置)
var initialX = 0
var initialY = 0
var initialTouchX = 0f
var initialTouchY = 0f
var isMoving = false
bubbleView?.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
initialX = params.x
initialY = params.y
initialTouchX = event.rawX
initialTouchY = event.rawY
isMoving = false
true
}
MotionEvent.ACTION_MOVE -> {
val dx = (event.rawX - initialTouchX).toInt()
val dy = (event.rawY - initialTouchY).toInt()
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
isMoving = true
}
params.x = initialX + dx
params.y = initialY + dy
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
true
}
MotionEvent.ACTION_UP -> {
if (!isMoving) {
toggleMenu()
} else {
// 拖动抬起时自动吸附到屏幕边缘(指示器中心对齐边缘)
if (params.x < service.resources.displayMetrics.widthPixels / 2) {
params.x = -dp(1.5f) // 指示器 3dp 宽,中心对齐边缘
} else {
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
}
containerView?.let { windowManager.updateViewLayout(it, params) }
lastX = params.x
lastY = params.y
}
true
}
else -> false
}
}
windowManager.addView(containerView, params)
Log.i(TAG, "记账助手悬浮窗显示成功")
} catch (e: Exception) {
Log.e(TAG, "记账助手悬浮窗创建失败: ${e.message}", e)
// 修复:失败时清理状态,允许下次重试
containerView = null
bubbleView = null
menuView = null
}
}
private fun toggleMenu() {
if (isExpanded) {
collapse()
} else {
expand()
}
}
private fun expand() {
bubbleView?.visibility = View.GONE
menuView?.visibility = View.VISIBLE
isExpanded = true
}
fun collapse() {
menuView?.visibility = View.GONE
bubbleView?.visibility = View.VISIBLE
isExpanded = false
}
fun hideTemporarily() {
containerView?.visibility = View.GONE
}
fun showTemporarily() {
containerView?.visibility = View.VISIBLE
}
fun dismiss() {
try {
containerView?.let { windowManager.removeView(it) }
} catch (_: Exception) {}
containerView = null
bubbleView = null
menuView = null
isExpanded = false
}
}
/**
* 扫码/OCR 矢量图标 drawable。
*/
class ScanIconDrawable(
private val color: Int,
private val strokeWidthPx: Float,
private val laserColor: Int,
private val sizePx: Int
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
strokeCap = Paint.Cap.ROUND
}
override fun draw(canvas: Canvas) {
val w = bounds.width().toFloat()
val h = bounds.height().toFloat()
paint.color = color
paint.style = Paint.Style.STROKE
// 绘制 4 个角的扫描框
val len = w * 0.25f
val pad = strokeWidthPx
// 左上
canvas.drawLine(pad, pad, pad + len, pad, paint)
canvas.drawLine(pad, pad, pad, pad + len, paint)
// 右上
canvas.drawLine(w - pad, pad, w - pad - len, pad, paint)
canvas.drawLine(w - pad, pad, w - pad, pad + len, paint)
// 左下
canvas.drawLine(pad, h - pad, pad + len, h - pad, paint)
canvas.drawLine(pad, h - pad, pad, h - pad - len, paint)
// 右下
canvas.drawLine(w - pad, h - pad, w - pad - len, h - pad, paint)
canvas.drawLine(w - pad, h - pad, w - pad, h - pad - len, paint)
// 绘制扫描红线 (激光)
paint.style = Paint.Style.FILL
paint.color = laserColor
val laserY = h / 2f
canvas.drawRect(pad * 2f, laserY - strokeWidthPx / 2f, w - pad * 2f, laserY + strokeWidthPx / 2f, paint)
}
override fun getIntrinsicWidth() = sizePx
override fun getIntrinsicHeight() = sizePx
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}
/**
* 图钉/记住当前页 矢量图标 drawable。
*/
class PinIconDrawable(
private val color: Int,
private val strokeWidthPx: Float,
private val sizePx: Int
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = strokeWidthPx
strokeCap = Paint.Cap.ROUND
}
override fun draw(canvas: Canvas) {
val w = bounds.width().toFloat()
val h = bounds.height().toFloat()
paint.color = color
val cx = w / 2f
// 图钉头部 (帽)
paint.style = Paint.Style.FILL
val hatW = w * 0.35f
val hatH = h * 0.12f
canvas.drawRoundRect(RectF(cx - hatW, hatH, cx + hatW, hatH * 2.2f), strokeWidthPx, strokeWidthPx, paint)
// 图钉身体 (中)
val bodyW = w * 0.22f
canvas.drawRect(cx - bodyW, hatH * 2.2f, cx + bodyW, h * 0.58f, paint)
// 针尖 (底)
paint.style = Paint.Style.STROKE
canvas.drawLine(cx, h * 0.58f, cx, h - strokeWidthPx, paint)
}
override fun getIntrinsicWidth() = sizePx
override fun getIntrinsicHeight() = sizePx
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity() = PixelFormat.TRANSLUCENT
}