### 架构重构:三层分层目录化 - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
469 lines
18 KiB
Kotlin
469 lines
18 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: SelectToSpeakService
|
||
) {
|
||
companion object {
|
||
private const val TAG = "FloatingHelper"
|
||
private const val PREFS_NAME = "billing_accessibility_prefs"
|
||
private const val KEY_X = "floating_ball_x"
|
||
private const val KEY_Y = "floating_ball_y"
|
||
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_ACCESSIBILITY_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
|
||
|
||
// 加载浮层 UI 配置(颜色与文案均从 JS 下发的 config 读取,未下发时回退默认值)
|
||
val config = FloatingUiConfigStore.load(service)
|
||
|
||
// 预解析颜色
|
||
val colorAccent = FloatingUiConfigStore.parseColorOr(config.colors.accent, "#FF5E6AD2")
|
||
val colorAccentFg = FloatingUiConfigStore.parseColorOr(config.colors.accentFg, "#FFFFFFFF")
|
||
val colorCardBg = FloatingUiConfigStore.parseColorOr(config.colors.cardBg, "#F0050506")
|
||
val colorFgPrimary = FloatingUiConfigStore.parseColorOr(config.colors.fgPrimary, "#FFFFFFFF")
|
||
val colorFgSecondary = FloatingUiConfigStore.parseColorOr(config.colors.fgSecondary, "#FF9CA3AF")
|
||
val colorBorder = FloatingUiConfigStore.parseColorOr(config.colors.border, "#80222433")
|
||
val colorInputBg = FloatingUiConfigStore.parseColorOr(config.colors.inputBg, "#4012131A")
|
||
|
||
// 位置持久化:从 SharedPreferences 读取,回退到 companion 缓存
|
||
val prefs = service.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||
params.x = prefs.getInt(KEY_X, lastX)
|
||
params.y = prefs.getInt(KEY_Y, lastY)
|
||
lastX = params.x
|
||
lastY = params.y
|
||
|
||
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))
|
||
}
|
||
|
||
// 边缘指示竖线:accent 色取 RGB + 固定 0xB0 alpha(保持 70% 透明)
|
||
val indicatorColor = (colorAccent and 0x00FFFFFF) or 0xB0000000.toInt()
|
||
val indicatorView = View(context).apply {
|
||
background = GradientDrawable().apply {
|
||
shape = GradientDrawable.RECTANGLE
|
||
cornerRadius = dp(1.5f).toFloat()
|
||
setColor(indicatorColor)
|
||
}
|
||
layoutParams = FrameLayout.LayoutParams(dp(3f), dp(44f)).apply {
|
||
gravity = Gravity.CENTER_VERTICAL or Gravity.START
|
||
}
|
||
}
|
||
bubbleLayout.addView(indicatorView)
|
||
bubbleView = bubbleLayout
|
||
|
||
// 3. 创建展开菜单 (垂直布局,高紧凑度设计)
|
||
// 菜单背景:实色 cardBg(per plan §T4)
|
||
val menuBgColor = colorCardBg
|
||
// 菜单描边:border 色 + 半透明 alpha
|
||
val menuBorderColor = (colorBorder and 0x00FFFFFF) or 0x22000000.toInt()
|
||
val menuBg = GradientDrawable().apply {
|
||
shape = GradientDrawable.RECTANGLE
|
||
cornerRadius = dp(10f).toFloat()
|
||
setColor(menuBgColor)
|
||
setStroke(dp(0.8f), menuBorderColor)
|
||
}
|
||
|
||
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),
|
||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||
).apply {
|
||
leftMargin = dp(4f)
|
||
}
|
||
}
|
||
|
||
// Vector icons
|
||
val sizePx = dp(14f)
|
||
val strokePx = dp(1.4f).toFloat()
|
||
// ScanIconDrawable:accentFg 色(在 accent 底按钮上可见),laserColor 保留红色语义
|
||
val ocrIcon = ScanIconDrawable(colorAccentFg, strokePx, 0xFFF87171.toInt(), sizePx)
|
||
// PinIconDrawable:fgSecondary 色
|
||
val pinIcon = PinIconDrawable(colorFgSecondary, strokePx, sizePx)
|
||
|
||
// Button 1: 识别账单(主操作:accent 底 accentFg 字)
|
||
// 背景常时:accent 色取 RGB + 固定 0x1F alpha(约 12%)
|
||
val btnOcrBgNormal = (colorAccent and 0x00FFFFFF) or 0x1F000000.toInt()
|
||
// 按下加深:accent 色取 RGB + 固定 0x40 alpha(约 25%)
|
||
val btnOcrBgPressed = (colorAccent and 0x00FFFFFF) or 0x40000000.toInt()
|
||
|
||
val btnOcr = TextView(context).apply {
|
||
text = config.labels.ballOcr
|
||
setTextColor(colorAccentFg)
|
||
textSize = 11f
|
||
paint.isFakeBoldText = true
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(6f), 0, dp(6f), 0)
|
||
background = GradientDrawable().apply {
|
||
cornerRadius = dp(7f).toFloat()
|
||
setColor(btnOcrBgNormal)
|
||
}
|
||
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(btnOcrBgPressed)
|
||
}
|
||
}
|
||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||
view.background = GradientDrawable().apply {
|
||
cornerRadius = dp(7f).toFloat()
|
||
setColor(btnOcrBgNormal)
|
||
}
|
||
if (event.action == MotionEvent.ACTION_UP) {
|
||
view.performClick()
|
||
}
|
||
}
|
||
}
|
||
true
|
||
}
|
||
setOnClickListener {
|
||
service.triggerManualExtraction()
|
||
}
|
||
}
|
||
|
||
// Button 2: 记住此页(次要操作:inputBg 底 fgPrimary 字,per plan §T4)
|
||
// 背景常时:inputBg 色取 RGB + 固定 0x15 alpha
|
||
val btnRememberBgNormal = (colorInputBg and 0x00FFFFFF) or 0x15000000.toInt()
|
||
// 按下加深:inputBg 色取 RGB + 固定 0x30 alpha
|
||
val btnRememberBgPressed = (colorInputBg and 0x00FFFFFF) or 0x30000000.toInt()
|
||
|
||
val btnRemember = TextView(context).apply {
|
||
text = config.labels.ballRemember
|
||
setTextColor(colorFgPrimary)
|
||
textSize = 11f
|
||
paint.isFakeBoldText = true
|
||
gravity = Gravity.CENTER_VERTICAL
|
||
setPadding(dp(6f), 0, dp(6f), 0)
|
||
background = GradientDrawable().apply {
|
||
cornerRadius = dp(7f).toFloat()
|
||
setColor(btnRememberBgNormal)
|
||
}
|
||
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(btnRememberBgPressed)
|
||
}
|
||
}
|
||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||
view.background = GradientDrawable().apply {
|
||
cornerRadius = dp(7f).toFloat()
|
||
setColor(btnRememberBgNormal)
|
||
}
|
||
if (event.action == MotionEvent.ACTION_UP) {
|
||
view.performClick()
|
||
}
|
||
}
|
||
}
|
||
true
|
||
}
|
||
setOnClickListener {
|
||
try {
|
||
service.rememberCurrentPage()
|
||
FloatingTip(service, config.labels.rememberSuccess, FloatingTip.TipPosition.TOP, 2500L).show()
|
||
} catch (e: Exception) {
|
||
FloatingTip(service, "${config.labels.rememberFail}: ${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)
|
||
} else {
|
||
params.x = service.resources.displayMetrics.widthPixels - dp(24f) + dp(1.5f)
|
||
}
|
||
containerView?.let { windowManager.updateViewLayout(it, params) }
|
||
|
||
lastX = params.x
|
||
lastY = params.y
|
||
|
||
// 位置持久化:吸附后写入 SharedPreferences
|
||
prefs.edit()
|
||
.putInt(KEY_X, params.x)
|
||
.putInt(KEY_Y, params.y)
|
||
.apply()
|
||
}
|
||
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
|
||
}
|