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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
This commit is contained in:
fengmengqi
2026-07-28 20:57:37 +08:00
parent bf04400852
commit 6767dd538a
222 changed files with 8970 additions and 2500 deletions
@@ -13,7 +13,7 @@ import com.facebook.react.bridge.ReadableMap
/**
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
*
* BillingAccessibilityService 是 AccessibilityService 子类(非 RN 模块),
* SelectToSpeakService 是 AccessibilityService 子类(非 RN 模块),
* 其方法无法直接从 JS 调用。本模块作为中间层,通过 instance 静态引用
* 把 JS 调用委托给服务实例。
*
@@ -42,7 +42,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
@ReactMethod
fun isServiceRunning(promise: Promise) {
promise.resolve(BillingAccessibilityService.instance != null)
promise.resolve(SelectToSpeakService.instance != null)
}
/**
@@ -51,7 +51,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
*/
@ReactMethod
fun rememberCurrentPage(promise: Promise) {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
@@ -72,7 +72,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
@ReactMethod
fun triggerManualOcr(promise: Promise) {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
@@ -88,7 +88,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 获取所有已记住的页面签名列表。 */
@ReactMethod
fun getPageSignatures(promise: Promise) {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
val sigsSet = if (service != null) {
service.getPageSignatures()
} else {
@@ -114,7 +114,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 清空所有已记住的页面签名。 */
@ReactMethod
fun clearPageSignatures(promise: Promise) {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
if (service != null) {
service.clearPageSignatures()
} else {
@@ -132,7 +132,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 删除指定页面签名。 */
@ReactMethod
fun removePageSignature(signature: String, promise: Promise) {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
if (service != null) {
service.removePageSignature(signature)
} else {
@@ -155,7 +155,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
@ReactMethod
fun getPaymentPackages(promise: Promise) {
val arr = WritableNativeArray()
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) {
for (pkg in SelectToSpeakService.PAYMENT_PACKAGES) {
arr.pushString(pkg)
}
promise.resolve(arr)
@@ -164,7 +164,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
@ReactMethod
fun getTopApp(promise: Promise) {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
@@ -178,7 +178,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
@ReactMethod
fun bringAppToForeground(promise: Promise) {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
if (service == null) {
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
return
@@ -205,7 +205,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
draftId: String,
promise: Promise
) {
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
val context = SelectToSpeakService.instance ?: reactContext.currentActivity
if (context == null) {
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
return
@@ -241,8 +241,8 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
/** 设置悬浮球开关状态。 */
@ReactMethod
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
BillingAccessibilityService.floatingBallEnabled = enabled
val service = BillingAccessibilityService.instance
SelectToSpeakService.floatingBallEnabled = enabled
val service = SelectToSpeakService.instance
if (service == null) {
promise.resolve(true)
return
@@ -409,7 +409,7 @@ class FloatingBillView(
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
dismiss()
BillingAccessibilityService.instance?.bringAppToForeground()
SelectToSpeakService.instance?.bringAppToForeground()
}
}
@@ -24,7 +24,7 @@ import android.widget.TextView
* 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。
*/
class FloatingHelper(
private val service: BillingAccessibilityService
private val service: SelectToSpeakService
) {
companion object {
private const val TAG = "FloatingHelper"
@@ -52,7 +52,7 @@ data class FloatingUiConfigLabels(
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
/** 记住页面成功提示(不含 emoji)。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
/** 失败提示前缀(原生拼接 ': ' + e.message)。 */ val rememberFail: String = "记录失败",
/** BillingAccessibilityService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
/** SelectToSpeakService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
)
@@ -14,7 +14,7 @@ import android.app.PendingIntent
* - 用户下拉快速设置,点击「OCR 记账」磁贴触发一次手动 OCR
* - Android 14+ 用 PendingIntent + startActivityAndCollapse
*
* 触发后调用 BillingAccessibilityService.triggerManualOcr()。
* 触发后调用 SelectToSpeakService.triggerManualOcr()。
*/
class OcrTileService : TileService() {
@@ -38,9 +38,9 @@ class OcrTileService : TileService() {
triggerManualOcr()
}
/** 触发手动 OCR(通过 BillingAccessibilityService)。 */
/** 触发手动 OCR(通过 SelectToSpeakService)。 */
private fun triggerManualOcr() {
val service = BillingAccessibilityService.instance
val service = SelectToSpeakService.instance
if (service != null) {
service.triggerManualOcr()
return
@@ -29,16 +29,25 @@ import android.view.accessibility.AccessibilityNodeInfo
* - 横屏免打扰游戏/视频时不触发
* - ocrDoing 守卫防止重复触发
*
* 伪装说明plan.md 决策 3纯开源侧载保留无障碍伪装非应用商店分发
* 注意本服务类名在 manifest 中声明为 BillingAccessibilityService
* 伪装为系统服务包名 com.beancount.mobile.accessibility仅在侧载版本保留
* 伪装说明绕过微信 8.0.52+ 节点混淆
* 微信按 ComponentName包名/类名识别系统服务白名单TalkBackSelectToSpeak
* 系统服务不受节点混淆影响第三方服务则被混淆
* 本服务的 package + class 全部伪装为
* com.google.android.accessibility.selecttospeak.SelectToSpeakService
* 使微信将其识别为系统 SelectToSpeak 服务从而拿到未混淆的真实节点文本
*
* 伪装由 Config Pluginplugins/accessibility/app.plugin.js prebuild 时完成
* - kt 源码 package rewrite com.google.android.accessibility.selecttospeak
* - Manifest android:name 写成完整全限定名
*
* 注意plan.md 决策 3纯开源侧载保留无障碍伪装非应用商店分发
*
* 通过 DeviceEventEmitter 把识别事件推送到 JS automationStore
*
* src/domain/constants.ts 同步PAYMENT_PACKAGES
* 修改时需两边同时更新
*/
class BillingAccessibilityService : AccessibilityService() {
class SelectToSpeakService : AccessibilityService() {
companion object {
private const val TAG = "BillingAccessibility"
@@ -46,7 +55,7 @@ class BillingAccessibilityService : AccessibilityService() {
private const val PREF_PAGE_SIGNATURES = "page_signatures"
@Volatile
var instance: BillingAccessibilityService? = null
var instance: SelectToSpeakService? = null
private set
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
@@ -119,6 +128,7 @@ class BillingAccessibilityService : AccessibilityService() {
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
AccessibilityServiceInfo.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS or
AccessibilityServiceInfo.DEFAULT
notificationTimeout = 100L
}
@@ -151,35 +161,8 @@ class BillingAccessibilityService : AccessibilityService() {
// 页面切换时检查是否有已记住的签名需要触发文本提取
scheduleContentChange()
// 调试:如果是微信或支付宝,延迟 800ms 抓取并打印全屏无障碍文本内容
if (eventPackage == "com.tencent.mm" || eventPackage == "com.eg.android.AlipayGphone") {
handler.postDelayed({
val rootNode = rootInActiveWindow
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
try {
val map = WritableNativeMap().apply {
putString("package", eventPackage)
putString("activity", activityName)
val array = WritableNativeArray()
for (t in texts) {
array.pushString(t)
}
putArray("texts", array)
}
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("billingDebugNodes", map)
} catch (e: Exception) {
// 忽略
}
}
}, 800)
}
// 注意:自动监听调试日志已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)。
// 账单识别仅在用户点击悬浮球时触发(isManual=true),此时提取完整节点文本。
}
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
// 内容变化时也检查(防抖合并)
@@ -231,11 +214,8 @@ class BillingAccessibilityService : AccessibilityService() {
return // 未记住的页面不自动触发
}
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制
val rootNode = rootInActiveWindow
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
// 抓取并提取屏幕所有无障碍文本(含 WebView 子窗口),并推送至 JS 侧
val texts = dumpAllTexts()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
@@ -278,7 +258,7 @@ class BillingAccessibilityService : AccessibilityService() {
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
result.hardwareBuffer.close()
if (bitmap != null) {
val base64 = bitmapToBase64(bitmap)
val base64 = bitmapToBase64(bitmap, packageName)
bitmap.recycle()
// 推送到 JS 层(NativeEventEmitter
sendScreenshotEvent(base64, packageName)
@@ -338,7 +318,12 @@ class BillingAccessibilityService : AccessibilityService() {
}, 150)
}
/** 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。 */
/**
* 手动触发一次节点文本提取并发送到 JS从而让 JS 优先尝试直接文本解析
*
* 使用 dumpAllTexts() 覆盖所有窗口 WebView 子窗口而非仅 rootInActiveWindow
* 伪装生效后这条路径会成为微信账单页的主识别路径必须保证完整性
*/
fun triggerManualExtraction() {
handler.post {
floatingHelper?.collapse()
@@ -347,10 +332,7 @@ class BillingAccessibilityService : AccessibilityService() {
val activity = topActivity ?: ""
val sigKey = "$pkg|$activity"
val rootNode = rootInActiveWindow
val texts = mutableListOf<String>()
dumpNodeTexts(rootNode, texts)
rootNode?.recycle()
val texts = dumpAllTexts()
val reactContext = ReactContextHolder.context
if (reactContext != null) {
@@ -519,13 +501,15 @@ class BillingAccessibilityService : AccessibilityService() {
}
/** Bitmap → base64JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
private fun bitmapToBase64(bitmap: Bitmap): String {
private fun bitmapToBase64(bitmap: Bitmap, packageName: String = ""): String {
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
bitmap.copy(Bitmap.Config.ARGB_8888, false)
} else {
bitmap
}
// 诊断:分析截图有效性(区分「截到空白帧」与「有内容但 OCR 失败」)
analyzeScreenshot(softwareBitmap, packageName)
val baos = ByteArrayOutputStream()
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
if (softwareBitmap !== bitmap) {
@@ -534,6 +518,70 @@ class BillingAccessibilityService : AccessibilityService() {
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
}
/**
* 截图有效性诊断纯日志不影响主流程
*
* 通过网格采样统计像素输出
* - 尺寸采样数
* - 是否纯色所有采样像素颜色完全一致 疑似空白/纯色帧
* - 亮度均值与方差方差0 无内容方差高 有内容
* - 深色/浅色像素占比
*
* 用于定位微信 WebView 场景takeScreenshot 是否截到了未渲染的空白帧
*/
private fun analyzeScreenshot(bitmap: Bitmap, packageName: String) {
try {
val w = bitmap.width
val h = bitmap.height
if (w <= 0 || h <= 0) {
Log.w(TAG, "[截图诊断] pkg=$packageName 无效尺寸: ${w}x${h}")
return
}
// 网格采样:目标约 400 个采样点,避免大图全像素扫描阻塞
val stepX = Math.max(1, w / 20)
val stepY = Math.max(1, h / 20)
var sum = 0L
var sumSq = 0L
var count = 0
var firstPixel = 0
var isSolidColor = true
var darkCount = 0 // 亮度 < 30(近黑)
var lightCount = 0 // 亮度 > 225(近白)
for (y in 0 until h step stepY) {
for (x in 0 until w step stepX) {
val pixel = bitmap.getPixel(x, y)
if (count == 0) {
firstPixel = pixel
} else if (pixel != firstPixel) {
isSolidColor = false
}
// 亮度(ITU-R BT.601):0.299R + 0.587G + 0.114B
val r = (pixel shr 16) and 0xFF
val g = (pixel shr 8) and 0xFF
val b = pixel and 0xFF
val lum = (r * 299 + g * 587 + b * 114) / 1000
sum += lum
sumSq += lum.toLong() * lum
if (lum < 30) darkCount++
if (lum > 225) lightCount++
count++
}
}
val mean = if (count > 0) sum.toDouble() / count else 0.0
val variance = if (count > 0) sumSq.toDouble() / count - mean * mean else 0.0
val darkRatio = if (count > 0) darkCount.toDouble() / count else 0.0
val lightRatio = if (count > 0) lightCount.toDouble() / count else 0.0
// 判定:纯色 或 方差<10 视为「疑似空白帧」
val likelyBlank = isSolidColor || variance < 10.0
Log.i(TAG, "[截图诊断] pkg=$packageName 尺寸=${w}x${h} 采样=$count " +
"纯色=$isSolidColor 亮度均值=${"%.1f".format(mean)} 方差=${"%.1f".format(variance)} " +
"深色占比=${"%.2f".format(darkRatio)} 浅色占比=${"%.2f".format(lightRatio)} " +
"疑似空白帧=$likelyBlank base64大小约=${(w * h) / 8}字节")
} catch (e: Exception) {
Log.w(TAG, "[截图诊断] pkg=$packageName 分析异常: ${e.message}")
}
}
/** 辅助:构造 WritableNativeMap。 */
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
val map = WritableNativeMap()
@@ -566,13 +614,18 @@ class BillingAccessibilityService : AccessibilityService() {
}
}
/** 递归提取无障碍节点树的全部文本。 */
/** 递归提取无障碍节点树的全部文本text + contentDescription。 */
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
if (node == null) return
val text = node.text?.toString()
if (!text.isNullOrBlank()) {
list.add(text)
}
// 修复:微信 8.0.52+ 混淆了 text,但部分节点仍通过 contentDescription 暴露内容
val desc = node.contentDescription?.toString()
if (!desc.isNullOrBlank() && desc != text) {
list.add(desc)
}
val childCount = node.childCount
for (i in 0 until childCount) {
val child = node.getChild(i) ?: continue
@@ -581,6 +634,38 @@ class BillingAccessibilityService : AccessibilityService() {
}
}
/**
* 增强版文本提取先尝试 rootInActiveWindow若为空则遍历所有窗口
* 修复新版 Android WebView 可能不在 activeWindow 中暴露节点
* 需要通过 getWindows() 获取 WebView 子窗口的节点树
*/
private fun dumpAllTexts(): List<String> {
val texts = mutableListOf<String>()
// 1. 先尝试 rootInActiveWindow(常规路径)
val rootNode = rootInActiveWindow
if (rootNode != null) {
dumpNodeTexts(rootNode, texts)
rootNode.recycle()
}
// 2. 如果 activeWindow 为空或无文本,遍历所有窗口(WebView 子窗口可能在这里)
if (texts.isEmpty()) {
try {
val allWindows = windows
for (window in allWindows) {
val wRoot = window.root ?: continue
dumpNodeTexts(wRoot, texts)
wRoot.recycle()
}
} catch (e: Exception) {
Log.w(TAG, "getWindows() 遍历失败: ${e.message}")
}
}
return texts
}
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
if (node == null) return false
+36 -19
View File
@@ -2,7 +2,13 @@
* 无障碍服务 Config Pluginplan.md「3.6 无障碍服务」+「决策 4」)。
*
* 在 expo prebuild 时注册 Android 无障碍服务(manifest service + xml 配置)。
* 参考 AutoAccounting 的 SelectToSpeakService 伪装机制(侧载保留,plan.md 决策 3)。
*
* 伪装机制(绕过微信 8.0.52+ 节点混淆):
* 微信按 ComponentName(包名/类名)识别系统服务白名单。早期只伪装类名不伪装包名,
* 因此被微信识别为第三方服务、对节点 text/contentDescription 做混淆。
* 现在把 7 个 kt 文件整体移到 com.google.android.accessibility.selecttospeak 包,
* 并把 Manifest 服务名写成完整全限定名,让微信将其识别为系统 SelectToSpeak 服务,
* 从而拿到未混淆的真实节点文本。
*
* 注意:withAndroidManifest 的 modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application。
@@ -12,6 +18,16 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs');
const path = require('path');
/**
* 伪装目标包名:系统 SelectToSpeak 服务的完整包名。
* 7 个 kt 文件会被复制到此包路径下,Manifest 服务名也用此包的全限定名。
*/
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
/** SelectToSpeakService 的完整 ComponentNameManifest android:name 用)。 */
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
/** OcrTileService 的完整 ComponentName(与 Service 同包,保持同 package 引用)。 */
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
/**
* 从 src/domain/constants.ts 的 PAYMENT_PACKAGES 中提取包名列表。
* 这是唯一的真相来源,Kotlin 端通过此函数自动同步。
@@ -52,29 +68,28 @@ 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`;
// 7 个 kt 文件全部跟随 SelectToSpeakService 伪装到 FAKE_PACKAGE 包下,
// 不再使用应用真实包名(appId)。
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'accessibility');
// Kotlin 源码统一复制到 FAKE_PACKAGE 目录下(伪装为系统 SelectToSpeak 服务包名)。
// kt 源码里仍写 `com.beancount.mobile`,此处用正则替换为 FAKE_PACKAGE。
const fakePkgPath = FAKE_PACKAGE.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', fakePkgPath);
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
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}`);
// kt 源码 packagecom.beancount.mobile.accessibility,整体替换为 FAKE_PACKAGE。
// 注意正则要匹配到 .accessibility 后缀,否则会多出 .accessibility 导致与 Manifest 不一致。
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
content = content.replace(/import\s+com\.beancount\.mobile\.accessibility/g, `import ${FAKE_PACKAGE}`);
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
}
}
@@ -87,7 +102,7 @@ function withAccessibilityService(config) {
// 从 constants.ts 读取包名列表,自动同步到 Kotlin
const packages = readPaymentPackagesFromConstants();
if (packages) {
const ktFile = path.join(ktDest, 'BillingAccessibilityService.kt');
const ktFile = path.join(ktDest, 'SelectToSpeakService.kt');
if (fs.existsSync(ktFile)) {
let ktContent = fs.readFileSync(ktFile, 'utf8');
// 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... )
@@ -117,6 +132,8 @@ function withAccessibilityService(config) {
]);
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
// 注意:AccessibilityBridgePackage 跟随其他 kt 文件一起被复制到 FAKE_PACKAGE 下,
// 因此 import 路径要用 FAKE_PACKAGE 而非 PACKAGE(应用包名)。
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
@@ -124,7 +141,7 @@ function withAccessibilityService(config) {
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
`$1\nimport ${FAKE_PACKAGE}.AccessibilityBridgePackage`,
);
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
@@ -160,10 +177,10 @@ function withAccessibilityService(config) {
manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } });
}
// 1. 添加无障碍服务声明
// 1. 添加无障碍服务声明(服务名使用伪装包的全限定名,以匹配微信系统服务白名单)
const serviceNode = {
$: {
'android:name': `${PACKAGE}.BillingAccessibilityService`,
'android:name': FAKE_SERVICE_NAME,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '浮记-账单识别',
'android:exported': 'false',
@@ -188,7 +205,7 @@ function withAccessibilityService(config) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === `${PACKAGE}.BillingAccessibilityService`
s => s.$['android:name'] === FAKE_SERVICE_NAME
);
if (!exists) {
app.service.push(serviceNode);
@@ -197,7 +214,7 @@ function withAccessibilityService(config) {
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': `${PACKAGE}.OcrTileService`,
'android:name': FAKE_TILE_SERVICE_NAME,
'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
@@ -208,7 +225,7 @@ function withAccessibilityService(config) {
}],
};
const tileExists = app.service.some(
s => s.$['android:name'] === `${PACKAGE}.OcrTileService`
s => s.$['android:name'] === FAKE_TILE_SERVICE_NAME
);
if (!tileExists) {
app.service.push(tileServiceNode);
@@ -8,7 +8,7 @@ import android.util.Log
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableNativeMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.beancount.mobile.accessibility.BillingAccessibilityService
import com.beancount.mobile.accessibility.SelectToSpeakService
import com.beancount.mobile.accessibility.ReactContextHolder
/**
@@ -16,7 +16,7 @@ import com.beancount.mobile.accessibility.ReactContextHolder
*
* 参考 AutoAccounting 的 NotificationListenerService
* - 提取支付 App 通知的 title/text
* - 白名单过滤(复用 BillingAccessibilityService.PAYMENT_PACKAGES,单一数据源)
* - 白名单过滤(复用 SelectToSpeakService.PAYMENT_PACKAGES,单一数据源)
* - 关键词黑白名单(JS 层 keywordFilter 进一步过滤)
* - MD5 去重(JS 层 NotificationChannel 处理,避免原生持有状态)
* - onListenerDisconnected 时 requestRebind 自动重连
@@ -34,7 +34,7 @@ class BillingNotificationListenerService : NotificationListenerService() {
runCatching {
val packageName = sbn?.packageName?.toString() ?: return
// 白名单过滤(复用无障碍服务的 PAYMENT_PACKAGES
if (!BillingAccessibilityService.PAYMENT_PACKAGES.contains(packageName)) return
if (!SelectToSpeakService.PAYMENT_PACKAGES.contains(packageName)) return
val notification = sbn.notification
val extras = notification.extras
@@ -15,6 +15,15 @@ function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/**
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
* 本服务的 Kotlin 源码 import 了 SelectToSpeakService/ReactContextHolder
* 而这些类在 prebuild 时被 accessibility plugin 整体迁移到此伪装包下,
* 因此本 plugin 复制源码时必须把对 accessibility 子包的引用一并改写到此包,
* 否则会产生 "Unresolved reference" 编译错误。
*/
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
function withNotificationListener(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.notification`;
@@ -31,6 +40,9 @@ function withNotificationListener(config) {
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
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(dest, f), content, 'utf8');
+175 -113
View File
@@ -24,6 +24,8 @@ import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
import java.nio.FloatBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
import kotlin.math.max
import kotlin.math.min
@@ -64,6 +66,8 @@ class OcrModule(private val context: ReactApplicationContext) :
/** 模型文件目录(filesystem 绝对路径)。非空时从该目录加载模型,否则回退到 assets。 */
@Volatile private var modelDir: String? = null
/** 初始化完成信号,ensureReady 可等待异步 initEngine 完成(最多等 15 秒)。 */
@Volatile private var initLatch = CountDownLatch(1)
override fun getName(): String = OCR_MODULE_NAME
@@ -115,9 +119,11 @@ class OcrModule(private val context: ReactApplicationContext) :
ortEnv = env
dictionary = dict
initialized = true
initLatch.countDown()
Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功(det+rec, dict=${dict.size}")
} catch (e: Exception) {
initFailed = true
initLatch.countDown()
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
val dir = modelDir
if (dir != null) {
@@ -181,7 +187,7 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled)
val text = blocks.joinToString("\n") { it.text }
promise.resolve(text)
@@ -213,7 +219,7 @@ class OcrModule(private val context: ReactApplicationContext) :
promise.reject("DECODE_FAILED", "base64 解码失败")
return@launch
}
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
scaled = capLongEdge(bitmap, CAP_LONG_EDGE)
val blocks = runInference(scaled)
// 序列化为 RN WritableArray
val arr = WritableNativeArray()
@@ -249,24 +255,38 @@ class OcrModule(private val context: ReactApplicationContext) :
/** 设置模型文件目录(绝对路径)。若引擎已初始化则释放并重新加载。 */
@ReactMethod
fun setModelDir(dir: String, promise: Promise) {
modelDir = dir
if (initialized) {
release()
initFailed = false
scope.launch { initEngine() }
}
// 修复:expo-file-system 返回 file:// URI,需转为文件系统绝对路径
val newDir = dir.removePrefix("file://")
// 修复:目录未变且引擎已就绪时跳过重新加载,避免冗余 release 导致竞态
if (newDir == modelDir && initialized) {
promise.resolve(true)
return
}
modelDir = newDir
// 修复:无论之前是成功还是失败,设置新目录后都应重新加载模型
if (initialized || initFailed) {
release()
initFailed = false
initLatch = CountDownLatch(1)
scope.launch { initEngine() }
}
promise.resolve(true)
}
// ============== 推理流水线 ==============
private fun ensureReady() {
if (!initialized && !initFailed) initEngine()
// 修复:异步 initEngine 进行中时等待完成(最多 15 秒),而非立即抛异常
if (!initialized && !initFailed) {
initLatch.await(15, TimeUnit.SECONDS)
}
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"}")
}
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
val totalStartTime = System.currentTimeMillis()
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
val det = detSession ?: run {
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
@@ -282,8 +302,14 @@ class OcrModule(private val context: ReactApplicationContext) :
var detOutputs: OrtSession.Result? = null
val results = mutableListOf<OcrBlock>()
var detTime = 0L
var postTime = 0L
var recTime = 0L
var boxCount = 0
try {
// ---- 1. 文本检测(DB----
val detStartTime = System.currentTimeMillis()
resized = resizeForDet(bitmap, DET_LIMIT_MAX_SIDE)
Log.i(OCR_MODULE_NAME, "det 图像缩放后尺寸 = ${resized.width}x${resized.height}")
val ratioX = bitmap.width.toFloat() / resized.width
@@ -292,18 +318,30 @@ class OcrModule(private val context: ReactApplicationContext) :
val detInput = preprocessDet(resized)
detInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(detInput.data), longArrayOf(1L, 3L, detInput.h.toLong(), detInput.w.toLong()))
val detInputs = mapOf("x" to detInputTensor)
val detRunStart = System.currentTimeMillis()
detOutputs = det.run(detInputs)
detTime = System.currentTimeMillis() - detRunStart
@Suppress("UNCHECKED_CAST")
val detProb = (detOutputs[0].value as Array<Array<Array<FloatArray>>>)[0][0] // [H,W]
Log.i(OCR_MODULE_NAME, "det 推理完成,概率图尺寸 = ${detProb.size}x${detProb[0].size}")
Log.i(OCR_MODULE_NAME, "det 推理完成 (耗时: ${detTime}ms),概率图尺寸 = ${detProb.size}x${detProb[0].size}")
// DB 后处理:threshold → 轮廓 → 最小外接矩形
val postStart = System.currentTimeMillis()
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}")
postTime = System.currentTimeMillis() - postStart
boxCount = boxes.size
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成 (耗时: ${postTime}ms),检测到文本框数量 = ${boxCount}")
if (boxes.isEmpty()) return emptyList()
if (boxes.isEmpty()) {
val totalTime = System.currentTimeMillis() - totalStartTime
Log.i(OCR_MODULE_NAME, "PP-OCRv6 推理完成(无文本): 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms)")
return emptyList()
}
// ---- 2. 文本识别(CRNN+CTC----
val recStart = System.currentTimeMillis()
for ((idx, box) in boxes.withIndex()) {
var crop: Bitmap? = null
var recInputTensor: OnnxTensor? = null
@@ -341,6 +379,7 @@ class OcrModule(private val context: ReactApplicationContext) :
recOutputs?.close()
}
}
recTime = System.currentTimeMillis() - recStart
} finally {
if (resized !== bitmap) {
resized?.recycle()
@@ -349,6 +388,12 @@ class OcrModule(private val context: ReactApplicationContext) :
detOutputs?.close()
}
val totalTime = System.currentTimeMillis() - totalStartTime
Log.i(
OCR_MODULE_NAME,
"PP-OCRv6 ONNX 推理整体完成: 总耗时 = ${totalTime}ms (det模型推理 = ${detTime}ms, det后处理 = ${postTime}ms, rec文本识别 = ${recTime}ms, 识别文本框数 = ${results.size}/${boxCount})"
)
return results
}
@@ -370,8 +415,8 @@ class OcrModule(private val context: ReactApplicationContext) :
for (y in 0 until h) {
for (x in 0 until w) {
val px = pixels[y * w + x]
// 提取 R/G/Bc=0→R, 1→G, 2→B
val channelVal = (px shr (16 - 8 * c)) and 0xFF
// 对齐官方训练通道序 BGRc=0→B, 1→G, 2→R);mean/std 数值按 BGR 顺序配套
val channelVal = (px shr (8 * c)) and 0xFF
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
}
}
@@ -383,9 +428,10 @@ class OcrModule(private val context: ReactApplicationContext) :
private fun preprocessRec(bmp: Bitmap): TensorData {
var w = bmp.width
val h = bmp.height
// resize 到高度 48,宽度等比缩放
var resizedW = (w.toFloat() / h * REC_IMAGE_HEIGHT).toInt()
// 宽度上限,避免单行过长爆显存
// resize 到高度 48,宽度等比缩放(ceil 取整对齐官方,避免右缘字符因 floor 被裁)
// 注:java.lang.Math.ceil 仅有 double 重载,故用 toDouble()Math.round 有 float 重载所以无需)
var resizedW = Math.ceil(w.toDouble() / h * REC_IMAGE_HEIGHT).toInt()
// 宽度上限,避免单行过长爆显存(官方 cap=3200,移动端折中 REC_MAX_WIDTH=1280
resizedW = min(resizedW, REC_MAX_WIDTH)
resizedW = max(resizedW, 1)
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
@@ -411,9 +457,9 @@ class OcrModule(private val context: ReactApplicationContext) :
return TensorData(data, w, REC_IMAGE_HEIGHT)
}
// ============== DB 后处理(简化版 ==============
// 参考 PaddleOCR db_postprocesssigmoid → threshold → 连通域 → 最小外接矩形
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够
// ============== DB 后处理(连通域法,对齐 PaddleOCR 官方 ==============
// sigmoid → 阈值二值化4-连通域标记每域 bbox 按官方 unclip 公式外扩 → box_score_fast 过滤。
// 取代旧的「水平/垂直投影法」:投影法会把基线孤立小数点切到行外导致金额丢点(¥143.97→¥14397
/**
* DB 后处理:sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
@@ -438,6 +484,7 @@ class OcrModule(private val context: ReactApplicationContext) :
val endY = (h * 0.92).toInt()
val binMask = Array(h) { IntArray(w) }
val sigMap = Array(h) { FloatArray(w) } // 概率图(sigmoid 后),供连通域 box_score_fast 取文本像素均值
var activeCountTotal = 0
for (y in 0 until h) {
for (x in 0 until w) {
@@ -451,6 +498,7 @@ class OcrModule(private val context: ReactApplicationContext) :
} else {
raw
}
sigMap[y][x] = sig
val isActive = if (sig > DET_THRESH) 1 else 0
binMask[y][x] = isActive
if (isActive == 1) activeCountTotal++
@@ -475,79 +523,76 @@ class OcrModule(private val context: ReactApplicationContext) :
}
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w")
// 水平投影:按行找文本行
val rowHits = IntArray(h)
// 连通域标记(4-连通,迭代 BFS 防栈溢出):取代旧的「水平投影切行 + 垂直投影切列」。
// 投影法会把基线/顶线上的孤立标点(小数点等,该行水平投影 < w/20)切到行外,
// 导致 crop 不含标点、rec 漏识(金额 ¥143.97 → ¥14397 的根因,已用官方同模型对照坐实)。
// 连通域天然把「数字 + 基线点」归为同一域,根治丢点;并正确处理断裂字符与多栏版面。
val labels = Array(h) { IntArray(w) }
var nComp = 0
val compX0 = mutableListOf<Int>()
val compY0 = mutableListOf<Int>()
val compX1 = mutableListOf<Int>()
val compY1 = mutableListOf<Int>()
val compArea = mutableListOf<Int>()
val compSum = mutableListOf<Float>() // 域内文本像素 sig 累加,供 box_score_fast 取均值
val stack = ArrayDeque<IntArray>()
for (y in 0 until h) {
var sum = 0
for (x in 0 until w) sum += binMask[y][x]
rowHits[y] = sum
}
val minRowWidth = max(1, w / 20) // 一行至少要有这么多像素才算文本
val rowRanges = mutableListOf<IntArray>()
var inLine = false
var lineStart = 0
for (y in 0 until h) {
val isText = rowHits[y] >= minRowWidth
if (isText && !inLine) { inLine = true; lineStart = y }
else if (!isText && inLine) {
rowRanges.add(intArrayOf(lineStart, y - 1))
inLine = false
for (x in 0 until w) {
if (binMask[y][x] == 1 && labels[y][x] == 0) {
nComp++
val id = nComp
var x0 = x; var y0 = y; var x1 = x; var y1 = y
var area = 0; var sum = 0f
stack.clear()
stack.addLast(intArrayOf(x, y))
labels[y][x] = id
while (stack.isNotEmpty()) {
val p = stack.removeLast()
val px = p[0]; val py = p[1]
area++
sum += sigMap[py][px]
if (px < x0) x0 = px; if (px > x1) x1 = px
if (py < y0) y0 = py; if (py > y1) y1 = py
if (px > 0 && binMask[py][px - 1] == 1 && labels[py][px - 1] == 0) { labels[py][px - 1] = id; stack.addLast(intArrayOf(px - 1, py)) }
if (px < w - 1 && binMask[py][px + 1] == 1 && labels[py][px + 1] == 0) { labels[py][px + 1] = id; stack.addLast(intArrayOf(px + 1, py)) }
if (py > 0 && binMask[py - 1][px] == 1 && labels[py - 1][px] == 0) { labels[py - 1][px] = id; stack.addLast(intArrayOf(px, py - 1)) }
if (py < h - 1 && binMask[py + 1][px] == 1 && labels[py + 1][px] == 0) { labels[py + 1][px] = id; stack.addLast(intArrayOf(px, py + 1)) }
}
compX0.add(x0); compY0.add(y0); compX1.add(x1); compY1.add(y1)
compArea.add(area); compSum.add(sum)
}
}
}
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1))
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成,找到行Ranges数 = ${rowRanges.size}")
Log.i(OCR_MODULE_NAME, "dbPostprocess 连通域标记完成,连通域数 = $nComp")
val boxes = mutableListOf<List<FloatArray>>()
// 对每行做垂直投影切列(账单每行通常是连续一段或多段)
for ((y0, y1) in rowRanges.map { it[0] to it[1] }) {
val colHits = IntArray(w)
for (x in 0 until w) {
var sum = 0
for (y in y0..y1) sum += binMask[y][x]
colHits[x] = sum
}
val minColHeight = max(1, (y1 - y0 + 1) / 12)
var inSeg = false
var segStart = 0
var segs = mutableListOf<IntArray>()
for (x in 0 until w) {
val isText = colHits[x] >= minColHeight
if (isText && !inSeg) { inSeg = true; segStart = x }
else if (!isText && inSeg) {
// 合并间隔很近的段
if (segs.isNotEmpty() && segStart - segs.last()[1] < DET_MERGE_GAP) {
segs.last()[1] = x - 1
} else {
segs.add(intArrayOf(segStart, x - 1))
}
inSeg = false
}
}
if (inSeg) {
if (segs.isNotEmpty() && (w - 1) - segs.last()[1] < DET_MERGE_GAP) {
segs.last()[1] = w - 1
} else {
segs.add(intArrayOf(segStart, w - 1))
}
}
for ((x0, x1) in segs.map { it[0] to it[1] }) {
// 过滤过小的框
val boxW = x1 - x0 + 1
val boxH = y1 - y0 + 1
if (boxW < 4 || boxH < 2) continue
// 映射回原图坐标(4 个角点)
val fx0 = x0 * ratioX
val fx1 = x1 * ratioX
val fy0 = y0 * ratioY
val fy1 = y1 * ratioY
boxes.add(listOf(
floatArrayOf(fx0, fy0),
floatArrayOf(fx1, fy0),
floatArrayOf(fx1, fy1),
floatArrayOf(fx0, fy1),
))
}
var dropSmall = 0; var dropScore = 0
for (i in 0 until nComp) {
val bx0 = compX0[i]; val by0 = compY0[i]; val bx1 = compX1[i]; val by1 = compY1[i]
val bw = bx1 - bx0 + 1; val bh = by1 - by0 + 1
val area = compArea[i]
val perim = 2 * (bw + bh)
// 官方 unclip 公式:distance = 连通域面积 × 比例 / 周长(水平文本下≈各向同性外扩,把基线标点纳入框)
var d = if (perim > 0) area * UNCLIP_RATIO / perim else 0f
if (d < 1f) d = 1f
val nx0 = max(0, Math.round(bx0 - d))
val ny0 = max(0, Math.round(by0 - d))
val nx1 = min(w - 1, Math.round(bx1 + d))
val ny1 = min(h - 1, Math.round(by1 + d))
if ((nx1 - nx0 + 1) < MIN_SIZE || (ny1 - ny0 + 1) < MIN_SIZE) { dropSmall++; continue }
// box_score_fast:域内文本像素 prob 均值(非整 bbox 均值,否则被背景稀释而误杀)
val score = compSum[i] / area
if (score < BOX_THRESH) { dropScore++; continue }
val fx0 = nx0 * ratioX; val fy0 = ny0 * ratioY
val fx1 = nx1 * ratioX; val fy1 = ny1 * ratioY
boxes.add(listOf(
floatArrayOf(fx0, fy0),
floatArrayOf(fx1, fy0),
floatArrayOf(fx1, fy1),
floatArrayOf(fx0, fy1),
))
}
Log.i(OCR_MODULE_NAME, "dbPostprocess 取框完成: 扩后min_size丢=$dropSmall, box_score丢=$dropScore, 保留=${boxes.size}")
return boxes
}
@@ -606,18 +651,29 @@ class OcrModule(private val context: ReactApplicationContext) :
val w = maxX - minX
val h = maxY - minY
if (w < 2 || h < 2) return null
return Bitmap.createBitmap(bmp, minX, minY, w, h)
var crop = Bitmap.createBitmap(bmp, minX, minY, w, h)
// 竖排文本旋转 90°(对齐官方 get_rotate_crop_image:高/宽 ≥ 1.5 视为竖排,
// 否则 rec 模型按水平行识别会乱码;账单侧边竖排小字借此可识别)
if (crop.height >= crop.width * 1.5f) {
val m = android.graphics.Matrix()
m.postRotate(90f)
val rotated = Bitmap.createBitmap(crop, 0, 0, crop.width, crop.height, m, true)
if (rotated !== crop) crop.recycle()
crop = rotated
}
return crop
}
private fun resizeForDet(bmp: Bitmap, maxSide: Int): Bitmap {
val ratio = maxSide.toFloat() / max(bmp.width, bmp.height)
if (ratio >= 1f) return bmp
val newW = (bmp.width * ratio).toInt()
val newH = (bmp.height * ratio).toInt()
// 确保尺寸是 32 的倍数(det 模型下采样要求)
val alignedW = (newW / 32) * 32
val alignedH = (newH / 32) * 32
if (alignedW < 32 || alignedH < 32) return bmp
// 长边上限 + 无条件对齐 32(det 下采样要求),half-up 取整对齐官方 round。
// 关键修复:旧实现 ratio>=1 时直接 return 不对齐,若整图未预压且非 32 倍数会喂入非法尺寸,
// 导致 det 内部特征图广播报错;现无条件对齐,且 cap 后图通常 ≤32 倍数时仅轻微取整。
val ratioC = minOf(1f, maxSide.toFloat() / max(bmp.width, bmp.height))
val newW = Math.round(bmp.width * ratioC)
val newH = Math.round(bmp.height * ratioC)
val alignedW = max(32, Math.round(newW / 32f) * 32)
val alignedH = max(32, Math.round(newH / 32f) * 32)
if (alignedW == bmp.width && alignedH == bmp.height) return bmp
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
}
@@ -637,17 +693,18 @@ class OcrModule(private val context: ReactApplicationContext) :
}
/**
* 短边压缩到 maxShortEdge(参考 AutoAccounting scaleDownForOcr
* 像素量比 1440p 减少约 75%,识别速度大幅提升
* 整图长边上限降采样:仅当长边超过 cap 才按比例缩小(half-up 取整),否则原样返回
* 使 rec 的 crop 源尽量高清(手机截图通常不触发),仅防超大图 OOM
* 取代旧的「短边压 720」——旧法把整图先砍掉 75% 像素,叠加 det resize 后小数点等细笔画被严重淡化。
*/
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: Int): Bitmap {
private fun capLongEdge(bitmap: Bitmap, capLong: Int): Bitmap {
val width = bitmap.width
val height = bitmap.height
val shortEdge = minOf(width, height)
if (shortEdge <= maxShortEdge) return bitmap
val scale = maxShortEdge.toFloat() / shortEdge
val newWidth = (width * scale).toInt()
val newHeight = (height * scale).toInt()
val longEdge = maxOf(width, height)
if (longEdge <= capLong) return bitmap
val scale = capLong.toFloat() / longEdge
val newWidth = Math.round(width * scale)
val newHeight = Math.round(height * scale)
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
}
@@ -683,18 +740,23 @@ class OcrModule(private val context: ReactApplicationContext) :
private data class TensorData(val data: FloatArray, val w: Int, val h: Int)
companion object {
/** OCR 最大短边(参考 AutoAccounting OCR_MAX_SHORT_EDGE)。 */
private const val OCR_MAX_SHORT_EDGE = 720
/** det resize 最大边(PaddleOCR limit_max_side_len 默认值)。 */
private const val DET_LIMIT_MAX_SIDE = 960
/** DB 二值化阈值。 */
private const val DET_THRESH = 0.3f
/** 投影法合并相邻文本段的间隔(像素)。 */
private const val DET_MERGE_GAP = 10
/** 整图长边上限:仅超大图降采样防 OOM;手机截图(≤2400)不预压,使 rec 的 crop 源为高清原图。
* 对齐官方「只放大不缩小」语义的移动端折中(官方 max_side_limit=4000)。 */
private const val CAP_LONG_EDGE = 3000
/** det 输入长边上限(官方 OCR 管线不降采样;移动端为性能折中取 1600,旧值 960 会让小数点仅 1-2px 而糊掉)。 */
private const val DET_LIMIT_MAX_SIDE = 1600
/** DB 二值化阈值(对齐官方模型 inference.yml=0.2,对细笔画/小数点更敏感;噪声框由 BOX_THRESH 兜底)。 */
private const val DET_THRESH = 0.2f
/** box_score_fast 过滤阈值:连通域文本像素 prob 均值低于此值视为噪声框(对齐官方 OCR 管线=0.6)。 */
private const val BOX_THRESH = 0.6f
/** 官方 unclip 外扩比例:distance = 连通域面积 × 比例 / 周长。 */
private const val UNCLIP_RATIO = 1.5f
/** 外扩后文本框短边下限(像素),小于此值丢弃(对齐官方 min_size=5)。 */
private const val MIN_SIZE = 5
/** rec 固定图像高度。 */
private const val REC_IMAGE_HEIGHT = 48
/** rec 单行最大宽度。 */
private const val REC_MAX_WIDTH = 320
/** rec 单行最大宽度(官方 cap=3200,移动端折中 1280;旧值 320 会把长商户名水平压扁 5×+)。 */
private const val REC_MAX_WIDTH = 1280
/** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
+10
View File
@@ -16,6 +16,13 @@ function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/**
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
* ScreenshotModule import 了 ReactContextHolder,该类在 prebuild 时被
* accessibility plugin 整体迁移到此伪装包下,故此处需同步改写引用。
*/
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
function withScreenshotMonitor(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.screenshot`;
@@ -32,6 +39,9 @@ function withScreenshotMonitor(config) {
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
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(dest, f), content, 'utf8');
+37 -43
View File
@@ -1,50 +1,44 @@
const { withDangerousMod } = require('@expo/config-plugins');
const { withDangerousMod, withGradleProperties } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
function withSizeOptimization(config) {
// 1. 在 prebuild 时修改 gradle.properties (开启 R8/ProGuard 代码混淆 + 资源裁剪 + .so 库压缩)
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
const propertiesPath = path.join(projectRoot, 'gradle.properties');
if (fs.existsSync(propertiesPath)) {
let content = fs.readFileSync(propertiesPath, 'utf8');
// 限制打包架构为 arm64-v8a
if (content.includes('reactNativeArchitectures=')) {
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
} else {
content += '\nreactNativeArchitectures=arm64-v8a\n';
}
// 1. 开启 R8 代码混淆与摇树裁剪
if (!content.includes('android.enableMinifyInReleaseBuilds=')) {
content += 'android.enableMinifyInReleaseBuilds=true\n';
} else {
content = content.replace(/android\.enableMinifyInReleaseBuilds=.*/, 'android.enableMinifyInReleaseBuilds=true');
}
// 1. 开启无用资源裁剪
if (!content.includes('android.enableShrinkResourcesInReleaseBuilds=')) {
content += 'android.enableShrinkResourcesInReleaseBuilds=true\n';
} else {
content = content.replace(/android\.enableShrinkResourcesInReleaseBuilds=.*/, 'android.enableShrinkResourcesInReleaseBuilds=true');
}
// 2. 开启 .so 动态库在 APK 内的高倍率压缩 (useLegacyPackaging=true)
if (!content.includes('expo.useLegacyPackaging=')) {
content += 'expo.useLegacyPackaging=true\n';
} else {
content = content.replace(/expo\.useLegacyPackaging=.*/, 'expo.useLegacyPackaging=true');
}
fs.writeFileSync(propertiesPath, content, 'utf8');
}
return modConfig;
/**
* 在 gradle.properties 的 modResults 数组中设置某个 key(存在则更新,不存在则追加)。
*
* 必须用 withGradleProperties(走 expo 内存 mod 流程)而非 withDangerousMod(直接改文件),
* 否则会被 expo 的 gradleProperties base mod 在 write 阶段用内存快照全量覆盖。
* 典型症状:reactNativeArchitectures 改了不生效(被覆盖回全架构),
* 而 minify/shrink 等新 key 反而生效(不在 expo 内存快照里,未被覆盖)。
*/
function setGradleProperty(modResults, key, value) {
let found = false;
const updated = modResults.map((prop) => {
if (prop.type === 'property' && prop.key === key) {
found = true;
return { ...prop, value };
}
]);
return prop;
});
if (!found) {
updated.push({ type: 'property', key, value });
}
return updated;
}
function withSizeOptimization(config) {
// 1. 修改 gradle.properties(用 withGradleProperties 避免 base mod write 覆盖)
// - 限制打包架构为 arm64-v8a(加速构建、减小包体)
// - 开启 R8 混淆与资源裁剪
// - 开启 .so 高倍率压缩
config = withGradleProperties(config, (config) => {
let props = config.modResults;
props = setGradleProperty(props, 'reactNativeArchitectures', 'arm64-v8a');
props = setGradleProperty(props, 'android.enableMinifyInReleaseBuilds', 'true');
props = setGradleProperty(props, 'android.enableShrinkResourcesInReleaseBuilds', 'true');
props = setGradleProperty(props, 'expo.useLegacyPackaging', 'true');
config.modResults = props;
return config;
});
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包 + 字体裁剪
config = withDangerousMod(config, [
+10
View File
@@ -15,6 +15,13 @@ function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
/**
* 无障碍服务伪装目标包名(必须与 accessibility plugin 保持一致)。
* BillingSmsReceiver import 了 ReactContextHolder,该类在 prebuild 时被
* accessibility plugin 整体迁移到此伪装包下,故此处需同步改写引用。
*/
const ACCESSIBILITY_FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
function withSmsReceiver(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.sms`;
@@ -31,6 +38,9 @@ function withSmsReceiver(config) {
for (const f of fs.readdirSync(srcDir)) {
if (f.endsWith('.kt')) {
let content = fs.readFileSync(path.join(srcDir, f), 'utf8');
// 必须先改写 accessibility 子包引用(迁移到伪装包),再做通用包名替换,
// 否则通用规则会把 com.beancount.mobile.accessibility 错误地改成 appId.accessibility。
content = content.replace(/com\.beancount\.mobile\.accessibility/g, ACCESSIBILITY_FAKE_PACKAGE);
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(dest, f), content, 'utf8');