DriftLedger/plugins/accessibility/android/FloatingUiConfigStore.kt
fengmengqi 6767dd538a feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化
  - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
2026-07-28 20:57:37 +08:00

212 lines
12 KiB
Kotlin
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.

package com.beancount.mobile.accessibility
import android.content.Context
import android.graphics.Color
import android.util.Log
import com.facebook.react.bridge.ReadableMap
import org.json.JSONObject
/**
* 浮层 UI 颜色契约docs/ui-redesign-p6-plan.md「FloatingUiConfig 契约」)。
* 字段名camelCase与 src/services/floatingUiConfig.ts 的 FloatingUiConfig.colors 一一对应。
* 默认值 = 现状硬编码颜色JS 未推送时行为不变),注释标明出处。
*/
data class FloatingUiConfigColors(
/** 按钮/选中态背景。 */ val accent: String = "#FF5E6AD2", // 原 FloatingBillView saveBg / 分段选中态
/** accent 上的文字色。 */ val accentFg: String = "#FFFFFFFF", // 原 FloatingBillView 按钮文字色
/** 卡片底色(原 0x8C050506 半透明,默认实色化到 F0 保证可读)。 */
val cardBg: String = "#F0050506", // 原 FloatingBillView containerBg 0x8C050506
/** 输入框/未选中 chip 底色。 */ val inputBg: String = "#4012131A", // 原 FloatingBillView 输入框底 0x4012131A
val fgPrimary: String = "#FFFFFFFF", // 原 FloatingBillView 输入框文字色
val fgSecondary: String = "#FF9CA3AF", // 原 FloatingBillView 未选中 tab/chip 文字色
val border: String = "#80222433", // 原 FloatingBillView 输入框/chip 描边 0x80222433
/** financial.income。 */ val income: String = "#FF10B981", // 原 FloatingBillView 收入/转账 chip 选中色
/** financial.expense。 */ val expense: String = "#FFE11D48", // 原 FloatingBillView 支出账户 chip 选中色
/** financial.transfer。 */ val transfer: String = "#FF10B981", // 原 FloatingBillView 转账转入 chip 选中色
)
/**
* 浮层 UI 文案契约。
* 字段名camelCase与 src/services/floatingUiConfig.ts 的 FloatingUiConfig.labels 一一对应。
* 默认值 = 现状中文硬编码文案(去 emoji
*/
data class FloatingUiConfigLabels(
/** 浮窗标题。 */ val billTitle: String = "调整交易草稿",
val dirExpense: String = "支出",
val dirIncome: String = "收入",
val dirTransfer: String = "转账",
val amountLabel: String = "金额",
val payeeLabel: String = "交易对手",
val narrationLabel: String = "描述/备注",
val narrationHint: String = "输入交易叙述",
/** 支出方向分类行标签。 */ val categoryExpense: String = "交易分类",
/** 收入方向分类行标签。 */ val categoryIncome: String = "收入分类",
/** 转账方向第一行标签。 */ val transferTarget: String = "转入账户",
/** 支出方向账户行标签。 */ val accountExpense: String = "资金来源",
/** 收入方向账户行标签。 */ val accountIncome: String = "存入账户",
/** 转账方向账户行标签。 */ val accountTransfer: String = "转出账户",
val openApp: String = "打开应用",
val dismiss: String = "忽略",
val confirm: String = "确认入账",
/** 悬浮球「识别账单」。 */ val ballOcr: String = "识别账单",
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
/** 记住页面成功提示(不含 emoji。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
/** 失败提示前缀(原生拼接 ': ' + e.message。 */ val rememberFail: String = "记录失败",
/** SelectToSpeakService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
)
/** 原生浮层 UI 配置JS 经 AccessibilityBridge.setFloatingUiConfig 下发)。 */
data class FloatingUiConfig(
val colors: FloatingUiConfigColors = FloatingUiConfigColors(),
val labels: FloatingUiConfigLabels = FloatingUiConfigLabels(),
)
/**
* FloatingUiConfig 的 SharedPreferences 持久化plan「原生持久化」节
*
* - 文件名 floating_ui_config单键 config_json 存整个 JSON。
* - save 采用合并策略:先 load 出现存 JSON再覆盖传入键避免部分更新丢字段。
* - load 逐字段 optString 缺省回退 data class 默认值;任何异常返回全默认。
*/
object FloatingUiConfigStore {
private const val TAG = "FloatingUiConfigStore"
private const val PREFS = "floating_ui_config"
private const val KEY = "config_json"
/** 契约内已知 colors 键save 时只写这些键)。 */
private val COLOR_KEYS = listOf(
"accent", "accentFg", "cardBg", "inputBg", "fgPrimary",
"fgSecondary", "border", "income", "expense", "transfer",
)
/** 契约内已知 labels 键save 时只写这些键)。 */
private val LABEL_KEYS = listOf(
"billTitle", "dirExpense", "dirIncome", "dirTransfer", "amountLabel",
"payeeLabel", "narrationLabel", "narrationHint", "categoryExpense",
"categoryIncome", "transferTarget", "accountExpense", "accountIncome",
"accountTransfer", "openApp", "dismiss", "confirm", "ballOcr",
"ballRemember", "rememberSuccess", "rememberFail", "pageRemembered",
"pageSignatureExists",
)
/** 保存(合并):先读出现存 JSON再覆盖传入的已知键。 */
fun save(context: Context, map: ReadableMap) {
val root = readRawJson(context)
if (map.hasKey("colors")) {
val colorsMap = map.getMap("colors")
val colorsJson = root.optJSONObject("colors") ?: JSONObject().also { root.put("colors", it) }
if (colorsMap != null) {
for (key in COLOR_KEYS) {
if (colorsMap.hasKey(key) && !colorsMap.isNull(key)) {
colorsJson.put(key, colorsMap.getString(key))
}
}
}
}
if (map.hasKey("labels")) {
val labelsMap = map.getMap("labels")
val labelsJson = root.optJSONObject("labels") ?: JSONObject().also { root.put("labels", it) }
if (labelsMap != null) {
for (key in LABEL_KEYS) {
if (labelsMap.hasKey(key) && !labelsMap.isNull(key)) {
labelsJson.put(key, labelsMap.getString(key))
}
}
}
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit()
.putString(KEY, root.toString())
.apply()
}
/** 读取配置;缺失字段回退默认值,任何异常返回全默认。 */
fun load(context: Context): FloatingUiConfig {
return try {
val root = readRawJson(context)
val defaultColors = FloatingUiConfigColors()
val defaultLabels = FloatingUiConfigLabels()
val colorsJson = root.optJSONObject("colors")
val labelsJson = root.optJSONObject("labels")
val colors = FloatingUiConfigColors(
accent = colorsJson.optStringOr("accent", defaultColors.accent),
accentFg = colorsJson.optStringOr("accentFg", defaultColors.accentFg),
cardBg = colorsJson.optStringOr("cardBg", defaultColors.cardBg),
inputBg = colorsJson.optStringOr("inputBg", defaultColors.inputBg),
fgPrimary = colorsJson.optStringOr("fgPrimary", defaultColors.fgPrimary),
fgSecondary = colorsJson.optStringOr("fgSecondary", defaultColors.fgSecondary),
border = colorsJson.optStringOr("border", defaultColors.border),
income = colorsJson.optStringOr("income", defaultColors.income),
expense = colorsJson.optStringOr("expense", defaultColors.expense),
transfer = colorsJson.optStringOr("transfer", defaultColors.transfer),
)
val labels = FloatingUiConfigLabels(
billTitle = labelsJson.optStringOr("billTitle", defaultLabels.billTitle),
dirExpense = labelsJson.optStringOr("dirExpense", defaultLabels.dirExpense),
dirIncome = labelsJson.optStringOr("dirIncome", defaultLabels.dirIncome),
dirTransfer = labelsJson.optStringOr("dirTransfer", defaultLabels.dirTransfer),
amountLabel = labelsJson.optStringOr("amountLabel", defaultLabels.amountLabel),
payeeLabel = labelsJson.optStringOr("payeeLabel", defaultLabels.payeeLabel),
narrationLabel = labelsJson.optStringOr("narrationLabel", defaultLabels.narrationLabel),
narrationHint = labelsJson.optStringOr("narrationHint", defaultLabels.narrationHint),
categoryExpense = labelsJson.optStringOr("categoryExpense", defaultLabels.categoryExpense),
categoryIncome = labelsJson.optStringOr("categoryIncome", defaultLabels.categoryIncome),
transferTarget = labelsJson.optStringOr("transferTarget", defaultLabels.transferTarget),
accountExpense = labelsJson.optStringOr("accountExpense", defaultLabels.accountExpense),
accountIncome = labelsJson.optStringOr("accountIncome", defaultLabels.accountIncome),
accountTransfer = labelsJson.optStringOr("accountTransfer", defaultLabels.accountTransfer),
openApp = labelsJson.optStringOr("openApp", defaultLabels.openApp),
dismiss = labelsJson.optStringOr("dismiss", defaultLabels.dismiss),
confirm = labelsJson.optStringOr("confirm", defaultLabels.confirm),
ballOcr = labelsJson.optStringOr("ballOcr", defaultLabels.ballOcr),
ballRemember = labelsJson.optStringOr("ballRemember", defaultLabels.ballRemember),
rememberSuccess = labelsJson.optStringOr("rememberSuccess", defaultLabels.rememberSuccess),
rememberFail = labelsJson.optStringOr("rememberFail", defaultLabels.rememberFail),
pageRemembered = labelsJson.optStringOr("pageRemembered", defaultLabels.pageRemembered),
pageSignatureExists = labelsJson.optStringOr("pageSignatureExists", defaultLabels.pageSignatureExists),
)
FloatingUiConfig(colors, labels)
} catch (e: Exception) {
Log.e(TAG, "读取浮层 UI 配置失败,回退默认值: ${e.message}")
FloatingUiConfig()
}
}
/** 解析颜色字符串;失败/为空时解析 fallbackHexfallback 也不合法则返回 Color.BLACK。 */
fun parseColorOr(value: String, fallbackHex: String): Int {
if (value.isNotBlank()) {
try {
return Color.parseColor(value)
} catch (_: Exception) {}
}
return try {
Color.parseColor(fallbackHex)
} catch (_: Exception) {
Color.BLACK
}
}
/** 读出现存 JSON无数据或解析失败返回空 JSONObject。 */
private fun readRawJson(context: Context): JSONObject {
return try {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY, null)
if (raw.isNullOrBlank()) JSONObject() else JSONObject(raw)
} catch (_: Exception) {
JSONObject()
}
}
/** optString 包装JSON 为 null / 键缺失 / 空串时回退 default。 */
private fun JSONObject?.optStringOr(key: String, default: String): String {
if (this == null) return default
val v = optString(key, default)
return if (v.isBlank()) default else v
}
}