feat: 重构渠道模型与管道架构,全面升级 UI 主题和报表功能
核心重构 — 去除 channel 字段,引入 sourceAccount 模型: - 从 ImportedEvent、Rule、EnhancedRule、OcrRule 等接口中彻底移除 channel 字段 - rules.ts 中 resolveChannelAccount → resolveSourceAccount,规则匹配与账户解析不再依赖渠道概念 - dedup.ts 重写去重逻辑:从基于渠道匹配改为基于交易对手(counterparty)匹配,支持相同金额/交易对手/时间窗口的多级置信度判断 - transferRecognizer.ts 增加资产负债表账户校验,确保转账双方均为 Assets/Liabilities 类账户 - 全局替换影响:types、rules、ocr、adapters、adapters-migrations、所有服务层和测试 新增基础设施: - domain/constants.ts — 统一常量定义(支付包名、截图关键词、去重参数、方向检测函数 detectDirection()),消除 OCR/SMS/截图等模块的重复定义 - domain/channelConfig.ts — 渠道配置系统(支付宝/微信/银行),支持按包名和名称查找 - domain/pipelineSingleton.ts — 共享 BillPipeline 单例,解决 importStore/automationStore 的互斥锁共享问题 - domain/transactionBuilder.ts — 统一交易构建入口 buildAndSaveTransaction(),同时服务手动录入和无障碍监听 OCR 增强: - 新增账单详情页解析(parseDetailPageBill),支持支付宝/微信详情页结构化提取 - checkIsDetailPage() 识别详情页特征词,防止误提取(如"消费1次"被误读为金额) - 金额正则支持千分位逗号分隔,商户名正则改用 lookahead 边界匹配 - 时间解析支持中文格式(年月日)和跨年推断 - OcrProcessor 新增详情页路由,跳过 Layer 1 规则匹配 UI 全面升级: - 主题重设计:accent 色从绿色改为靛蓝(#4F46E5),深色模式适配 OLED 纯黑,引入 Quicksand/Caveat 字体 - 新增 commonStyles.ts 统一 chip/input/modal 等通用样式 - 首页 Bento 网格布局:净资产英雄卡片 + 定期账单/月度统计并排展示 - 报表新增周报标签页,月报整合日历视图(支持点击查看当日交易明细) - TrendLine 图表从 View 条形图重写为 SVG 贝塞尔曲线 - CategoryPicker 从水平滚动改为 4 列网格 + emoji 图标 - Button/Card 增加 press 缩放动画 管道与自动化改进: - automationPipeline.ts 新增 handleIncomingBillEvent() 实时账单处理(悬浮账单卡片 + 前台 Alert 确认) - 新增无障碍文本直解析 parseAndProcessAccessibilityTexts(),微信/支付宝详情页绕过 OCR - rules.ts 新增智能还款检测(花呗/信用卡还款自动路由)和退款视为收入处理 - metadataStore 默认规则精简为 6 条通用规则,移除约 20 条个人化硬编码规则 存储与同步: - storePersistence.ts 原子写入 + 崩溃恢复 + 重试机制 - 备份升级到 v2 格式,包含 settings 和 metadata - 同步路径统一从 mobile.bean 改为 main.bean - _layout.tsx 启动时自动迁移旧 mobile.bean 到 main.bean 其他: - 删除独立日历页面,功能合并到报表月报标签 - i18n 清理:移除渠道相关翻译,新增 50+ 翻译键 - docs/android-build-guide.md 重写为 APK 体积优化指南 - 新增 design-system/beancount-mobile/MASTER.md 设计系统文档 - 测试全面更新覆盖以上所有变更
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.WritableNativeArray
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
|
||||
/**
|
||||
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
||||
*
|
||||
* BillingAccessibilityService 是 AccessibilityService 子类(非 RN 模块),
|
||||
* 其方法无法直接从 JS 调用。本模块作为中间层,通过 instance 静态引用
|
||||
* 把 JS 调用委托给服务实例。
|
||||
*
|
||||
* 由 Config Plugin 的 withMainApplication 注入 add(AccessibilityBridgePackage())。
|
||||
*/
|
||||
class AccessibilityBridgeModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
init {
|
||||
ReactContextHolder.context = reactContext
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
if (ReactContextHolder.context === reactContext) {
|
||||
ReactContextHolder.context = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun getName() = "AccessibilityBridge"
|
||||
|
||||
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
|
||||
@ReactMethod
|
||||
fun isServiceRunning(promise: Promise) {
|
||||
promise.resolve(BillingAccessibilityService.instance != null)
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住当前页面:把当前顶部 App 的 pkg|activity 加入白名单,
|
||||
* 之后该页面内容变化时自动截图 → OCR。
|
||||
*/
|
||||
@ReactMethod
|
||||
fun rememberCurrentPage(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
val pkg = service.getTopPackage()
|
||||
if (pkg == null) {
|
||||
promise.reject("NO_TOP_PACKAGE", "当前没有检测到前台 App")
|
||||
return
|
||||
}
|
||||
service.rememberCurrentPage()
|
||||
val result = WritableNativeMap()
|
||||
result.putString("package", pkg)
|
||||
result.putString("activity", service.getTopActivity() ?: "")
|
||||
result.putString("signature", "$pkg|${service.getTopActivity() ?: ""}")
|
||||
promise.resolve(result)
|
||||
}
|
||||
|
||||
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
|
||||
@ReactMethod
|
||||
fun triggerManualOcr(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
try {
|
||||
service.triggerManualOcr()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("OCR_TRIGGER_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取所有已记住的页面签名列表。 */
|
||||
@ReactMethod
|
||||
fun getPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val sigsSet = if (service != null) {
|
||||
service.getPageSignatures()
|
||||
} else {
|
||||
try {
|
||||
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
|
||||
prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
|
||||
} catch (e: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
val arr = WritableNativeArray()
|
||||
for (sig in sigsSet) {
|
||||
val parts = sig.split("|", limit = 2)
|
||||
val map = WritableNativeMap()
|
||||
map.putString("signature", sig)
|
||||
map.putString("package", parts.getOrNull(0) ?: "")
|
||||
map.putString("activity", parts.getOrNull(1) ?: "")
|
||||
arr.pushMap(map)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
}
|
||||
|
||||
/** 清空所有已记住的页面签名。 */
|
||||
@ReactMethod
|
||||
fun clearPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service != null) {
|
||||
service.clearPageSignatures()
|
||||
} else {
|
||||
try {
|
||||
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
|
||||
prefs.edit().putStringSet("page_signatures", emptySet()).apply()
|
||||
} catch (e: Exception) {
|
||||
promise.reject("CLEAR_PREFS_FAIL", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
/** 删除指定页面签名。 */
|
||||
@ReactMethod
|
||||
fun removePageSignature(signature: String, promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service != null) {
|
||||
service.removePageSignature(signature)
|
||||
} else {
|
||||
try {
|
||||
val prefs = reactContext.getSharedPreferences("billing_accessibility_prefs", android.content.Context.MODE_PRIVATE)
|
||||
val saved = prefs.getStringSet("page_signatures", emptySet()) ?: emptySet()
|
||||
val mutable = HashSet(saved)
|
||||
if (mutable.remove(signature)) {
|
||||
prefs.edit().putStringSet("page_signatures", mutable).apply()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
promise.reject("REMOVE_PREFS_FAIL", e.message)
|
||||
return
|
||||
}
|
||||
}
|
||||
promise.resolve(true)
|
||||
}
|
||||
|
||||
/** 获取支付 App 白名单(供 JS 端展示)。 */
|
||||
@ReactMethod
|
||||
fun getPaymentPackages(promise: Promise) {
|
||||
val arr = WritableNativeArray()
|
||||
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) {
|
||||
arr.pushString(pkg)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
}
|
||||
|
||||
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
|
||||
@ReactMethod
|
||||
fun getTopApp(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
val map = WritableNativeMap()
|
||||
map.putString("package", service.getTopPackage() ?: "")
|
||||
map.putString("activity", service.getTopActivity() ?: "")
|
||||
promise.resolve(map)
|
||||
}
|
||||
|
||||
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
|
||||
@ReactMethod
|
||||
fun bringAppToForeground(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
}
|
||||
try {
|
||||
service.bringAppToForeground()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FAIL", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示账单浮窗(直接在当前其他应用上方渲染,不返回 App 内) */
|
||||
@ReactMethod
|
||||
fun showFloatingBill(
|
||||
amount: String,
|
||||
merchant: String,
|
||||
time: String,
|
||||
packageName: String,
|
||||
categories: ReadableArray,
|
||||
accounts: ReadableArray,
|
||||
direction: String,
|
||||
draftId: String,
|
||||
promise: Promise
|
||||
) {
|
||||
val context = reactContext.currentActivity ?: BillingAccessibilityService.instance
|
||||
if (context == null) {
|
||||
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
||||
return
|
||||
}
|
||||
|
||||
val categoryList = mutableListOf<Map<String, String>>()
|
||||
for (i in 0 until categories.size()) {
|
||||
val map = categories.getMap(i)
|
||||
categoryList.add(mapOf(
|
||||
"id" to (map?.getString("id") ?: ""),
|
||||
"name" to (map?.getString("name") ?: ""),
|
||||
"account" to (map?.getString("account") ?: ""),
|
||||
"type" to (map?.getString("type") ?: "")
|
||||
))
|
||||
}
|
||||
|
||||
val accountList = mutableListOf<String>()
|
||||
for (i in 0 until accounts.size()) {
|
||||
accountList.add(accounts.getString(i) ?: "")
|
||||
}
|
||||
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post {
|
||||
try {
|
||||
val floatingView = FloatingBillView(context, draftId, amount, merchant, time, packageName, categoryList, accountList, direction)
|
||||
floatingView.show()
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("FAIL", e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* ReactPackage 注册 AccessibilityBridgeModule。
|
||||
* 由 Config Plugin 的 withMainApplication 注入 add(AccessibilityBridgePackage())。
|
||||
*/
|
||||
class AccessibilityBridgePackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(AccessibilityBridgeModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.accessibilityservice.AccessibilityService
|
||||
import android.accessibilityservice.AccessibilityServiceInfo
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Display
|
||||
import android.view.Surface
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.bridge.WritableNativeArray
|
||||
import java.io.ByteArrayOutputStream
|
||||
import android.util.Base64
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
|
||||
/**
|
||||
* 无障碍账单识别服务(plan.md「3.6 无障碍服务」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 SelectToSpeakService:
|
||||
* - 监听支付 App 的页面切换(TYPE_WINDOW_STATE_CHANGED)
|
||||
* - 页面签名匹配时自动截图 → OCR → 推送到 JS 层
|
||||
* - 横屏免打扰(游戏/视频时不触发)
|
||||
* - ocrDoing 守卫(防止重复触发)
|
||||
*
|
||||
* 伪装说明:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
||||
* 注意:本服务类名在 manifest 中声明为 BillingAccessibilityService,
|
||||
* 伪装为系统服务(包名 com.beancount.mobile.accessibility)仅在侧载版本保留。
|
||||
*
|
||||
* 通过 DeviceEventEmitter 把识别事件推送到 JS 层 automationStore。
|
||||
*
|
||||
* ⚠️ 与 src/domain/constants.ts 同步:PAYMENT_PACKAGES
|
||||
* 修改时需两边同时更新。
|
||||
*/
|
||||
class BillingAccessibilityService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingAccessibility"
|
||||
private const val PREFS_NAME = "billing_accessibility_prefs"
|
||||
private const val PREF_PAGE_SIGNATURES = "page_signatures"
|
||||
|
||||
@Volatile
|
||||
var instance: BillingAccessibilityService? = null
|
||||
private set
|
||||
|
||||
/** 支付 App 白名单。 */
|
||||
val PAYMENT_PACKAGES = setOf(
|
||||
"com.eg.android.AlipayGphone", // 支付宝
|
||||
"com.tencent.mm", // 微信
|
||||
"com.unionpay", // 银联
|
||||
"com.cmbchina", // 招商银行
|
||||
"com.icbc", // 工商银行
|
||||
"com.chinamworld.main", // 中国银行
|
||||
"com.ccbrcb", // 建设银行
|
||||
"com.bankcomm.Bankcomm", // 交通银行
|
||||
"com.tencent.mobileqq", // 手机QQ
|
||||
"com.tencent.tim" // TIM
|
||||
)
|
||||
|
||||
/** 厂商桌面包名(过滤,不触发 OCR)。 */
|
||||
private val LAUNCHER_PACKAGES = setOf(
|
||||
"com.google.android.apps.nexuslauncher",
|
||||
"com.sec.android.app.launcher",
|
||||
"com.miui.home",
|
||||
"com.huawei.android.launcher",
|
||||
"com.oppo.launcher",
|
||||
"com.bbk.launcher2",
|
||||
"com.android.launcher3",
|
||||
)
|
||||
|
||||
/** OCR 触发防抖:500ms 内的内容变化合并为一次。 */
|
||||
private const val CONTENT_CHANGE_DEBOUNCE_MS = 500L
|
||||
}
|
||||
|
||||
private var ocrDoing = false
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val debounceRunnable = Runnable { processContentChange() }
|
||||
@Volatile private var topPackage: String? = null
|
||||
@Volatile private var topActivity: String? = null
|
||||
/** 已记住的页面签名(pkg|activity),匹配时自动触发 OCR。持久化到 SharedPreferences。 */
|
||||
private val pageSignatures = java.util.concurrent.CopyOnWriteArraySet<String>()
|
||||
private var floatingHelper: FloatingHelper? = null
|
||||
|
||||
override fun onServiceConnected() {
|
||||
super.onServiceConnected()
|
||||
instance = this
|
||||
Log.i(TAG, "无障碍账单识别服务已连接")
|
||||
loadPageSignatures()
|
||||
configureService()
|
||||
}
|
||||
|
||||
/** 动态配置服务能力(截图 + 页面变化监听)。 */
|
||||
private fun configureService() {
|
||||
val info = AccessibilityServiceInfo().apply {
|
||||
eventTypes = AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED or
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED
|
||||
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
|
||||
flags = AccessibilityServiceInfo.FLAG_REQUEST_ENHANCED_WEB_ACCESSIBILITY or
|
||||
AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
|
||||
AccessibilityServiceInfo.DEFAULT
|
||||
notificationTimeout = 100L
|
||||
}
|
||||
serviceInfo = info
|
||||
}
|
||||
|
||||
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
|
||||
if (ocrDoing) return // 处理中,跳过
|
||||
val eventPackage = event?.packageName?.toString() ?: return
|
||||
|
||||
when (event.eventType) {
|
||||
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED -> {
|
||||
val activityName = event.className?.toString() ?: ""
|
||||
if (filterPackage(eventPackage, activityName)) return
|
||||
topPackage = eventPackage
|
||||
topActivity = activityName
|
||||
Log.d(TAG, "页面切换: $eventPackage / $activityName")
|
||||
|
||||
// 更新悬浮窗助手状态
|
||||
updateFloatingHelperVisibility(eventPackage)
|
||||
|
||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
||||
if (PAYMENT_PACKAGES.contains(eventPackage)) {
|
||||
scheduleContentChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFloatingHelperVisibility(pkg: String?) {
|
||||
handler.post {
|
||||
if (pkg != null && PAYMENT_PACKAGES.contains(pkg)) {
|
||||
if (floatingHelper == null) {
|
||||
floatingHelper = FloatingHelper(this)
|
||||
floatingHelper?.show()
|
||||
}
|
||||
} else {
|
||||
floatingHelper?.dismiss()
|
||||
floatingHelper = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 防抖:500ms 内的多次内容变化合并。 */
|
||||
private fun scheduleContentChange() {
|
||||
handler.removeCallbacks(debounceRunnable)
|
||||
handler.postDelayed(debounceRunnable, CONTENT_CHANGE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** 内容变化处理:检查页面签名 → 提取文本 → 发送给 JS(JS 控制是否 OCR 兜底)。 */
|
||||
private fun processContentChange() {
|
||||
if (ocrDoing) return
|
||||
val pkg = topPackage ?: return
|
||||
|
||||
// 横屏免打扰(plan.md「3.10」)
|
||||
if (isLandscape()) {
|
||||
Log.d(TAG, "横屏免打扰,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
// 页面签名匹配(若已记住页面则触发)
|
||||
val activity = topActivity ?: ""
|
||||
val sigKey = "$pkg|$activity"
|
||||
if (!pageSignatures.contains(sigKey)) {
|
||||
return // 未记住的页面不自动触发
|
||||
}
|
||||
|
||||
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制
|
||||
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", pkg)
|
||||
putString("activity", activity)
|
||||
putString("signature", sigKey)
|
||||
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) {
|
||||
Log.e(TAG, "推送内容变化节点文本失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 截图并触发 OCR 处理(Android 11+)。 */
|
||||
private fun takeScreenshotAndProcess(packageName: String) {
|
||||
if (ocrDoing) return
|
||||
ocrDoing = true
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
ocrDoing = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
takeScreenshot(
|
||||
Display.DEFAULT_DISPLAY,
|
||||
mainExecutor,
|
||||
object : TakeScreenshotCallback {
|
||||
override fun onSuccess(result: ScreenshotResult) {
|
||||
try {
|
||||
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||
result.hardwareBuffer.close()
|
||||
if (bitmap != null) {
|
||||
val base64 = bitmapToBase64(bitmap)
|
||||
bitmap.recycle()
|
||||
// 推送到 JS 层(NativeEventEmitter)
|
||||
sendScreenshotEvent(base64, packageName)
|
||||
}
|
||||
} finally {
|
||||
ocrDoing = false
|
||||
}
|
||||
}
|
||||
override fun onFailure(errorCode: Int) {
|
||||
Log.e(TAG, "截图失败: errorCode=$errorCode")
|
||||
ocrDoing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "调用 takeScreenshot 失败: ${e.message}", e)
|
||||
ocrDoing = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 把截图以 base64 推送到 JS 层(由 JS 端 OcrProcessor 处理)。 */
|
||||
private fun sendScreenshotEvent(base64: String, packageName: String) {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingScreenshot", writableMapOf(
|
||||
"base64" to base64,
|
||||
"packageName" to packageName,
|
||||
"timestamp" to System.currentTimeMillis()
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送截图事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 手动触发一次 OCR(临时隐藏悬浮窗避开遮挡,并在 150ms 后触发截图)。 */
|
||||
fun triggerManualOcr() {
|
||||
val pkg = topPackage ?: return
|
||||
handler.post {
|
||||
floatingHelper?.collapse()
|
||||
floatingHelper?.hideTemporarily()
|
||||
}
|
||||
|
||||
// 150ms 后触发截图(此时悬浮窗已瞬间隐藏,避免遮挡和误匹配)
|
||||
handler.postDelayed({
|
||||
try {
|
||||
takeScreenshotAndProcess(pkg)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "手动触发 OCR 失败: ${e.message}")
|
||||
} finally {
|
||||
// 截图完成,瞬间恢复显示悬浮球
|
||||
handler.post {
|
||||
floatingHelper?.showTemporarily()
|
||||
}
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/** 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。 */
|
||||
fun triggerManualExtraction() {
|
||||
handler.post {
|
||||
floatingHelper?.collapse()
|
||||
}
|
||||
val pkg = topPackage ?: return
|
||||
val activity = topActivity ?: ""
|
||||
val sigKey = "$pkg|$activity"
|
||||
|
||||
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", pkg)
|
||||
putString("activity", activity)
|
||||
putString("signature", sigKey)
|
||||
putBoolean("isManual", true)
|
||||
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) {
|
||||
Log.e(TAG, "手动触发节点文本提取失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 记住当前页面(用户主动标记应触发 OCR 的页面)。持久化到 SharedPreferences。 */
|
||||
fun rememberCurrentPage() {
|
||||
val pkg = topPackage ?: return
|
||||
val activity = topActivity ?: ""
|
||||
val sig = "$pkg|$activity"
|
||||
if (pageSignatures.add(sig)) {
|
||||
savePageSignatures()
|
||||
Log.i(TAG, "已记住页面: $sig")
|
||||
handler.post {
|
||||
android.widget.Toast.makeText(this, "已记住页面签名:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
// 抓取并提取屏幕所有无障碍文本
|
||||
val texts = mutableListOf<String>()
|
||||
val rootNode = rootInActiveWindow
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
|
||||
// 推送事件到 JS 端,使 npx expo start 终端控制台可以接收并打印日志
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("package", pkg)
|
||||
putString("activity", activity)
|
||||
putString("signature", sig)
|
||||
val array = WritableNativeArray()
|
||||
for (t in texts) {
|
||||
array.pushString(t)
|
||||
}
|
||||
putArray("texts", array)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingPageRemembered", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送记住页面事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
handler.post {
|
||||
android.widget.Toast.makeText(this, "该页面签名已存在:\n$sig", android.widget.Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取已记住的页面签名列表(供 JS 端展示)。 */
|
||||
fun getPageSignatures(): Set<String> {
|
||||
return pageSignatures.toSet()
|
||||
}
|
||||
|
||||
/** 清空所有已记住的页面签名。 */
|
||||
fun clearPageSignatures() {
|
||||
pageSignatures.clear()
|
||||
try {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
|
||||
Log.i(TAG, "已清空全部记住的页面并从 SharedPreferences 中移除键值")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "清空 SharedPreferences 失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除指定页面签名。 */
|
||||
fun removePageSignature(sig: String) {
|
||||
if (pageSignatures.remove(sig)) {
|
||||
savePageSignatures()
|
||||
Log.i(TAG, "已删除页面签名: $sig")
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取当前顶部包名(供 JS 判断当前页面)。 */
|
||||
fun getTopPackage(): String? = topPackage
|
||||
|
||||
/** 获取当前顶部 Activity。 */
|
||||
fun getTopActivity(): String? = topActivity
|
||||
|
||||
/** 从 SharedPreferences 加载已记住的页面签名。 */
|
||||
private fun loadPageSignatures() {
|
||||
try {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val saved = prefs.getStringSet(PREF_PAGE_SIGNATURES, emptySet()) ?: emptySet()
|
||||
pageSignatures.clear()
|
||||
pageSignatures.addAll(saved)
|
||||
Log.i(TAG, "已加载 ${pageSignatures.size} 个记住的页面签名")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "加载页面签名失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存页面签名到 SharedPreferences。 */
|
||||
private fun savePageSignatures() {
|
||||
try {
|
||||
val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (pageSignatures.isEmpty()) {
|
||||
prefs.edit().remove(PREF_PAGE_SIGNATURES).apply()
|
||||
} else {
|
||||
prefs.edit().putStringSet(PREF_PAGE_SIGNATURES, HashSet(pageSignatures)).apply()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "保存页面签名失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 横屏检测(plan.md「3.10 横屏免打扰」)。 */
|
||||
private fun isLandscape(): Boolean {
|
||||
val dm = getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY)
|
||||
?: return false
|
||||
val rotation = dm.rotation
|
||||
return rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270
|
||||
}
|
||||
|
||||
/** 过滤系统组件/桌面(不触发 OCR)。 */
|
||||
private fun filterPackage(pkg: String, className: String): Boolean {
|
||||
val p = pkg.lowercase()
|
||||
// 针对自身应用的特殊过滤:只允许 MainActivity 通过以触发隐藏悬浮窗;
|
||||
// 其他自身组件(如悬浮球容器 LinearLayout)一律过滤,避免自毁式关闭。
|
||||
if (pkg == packageName) {
|
||||
return className != "com.example.beanmobile.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
|
||||
return false
|
||||
}
|
||||
|
||||
/** Bitmap → base64(JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
|
||||
private fun bitmapToBase64(bitmap: Bitmap): 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
|
||||
}
|
||||
val baos = ByteArrayOutputStream()
|
||||
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
|
||||
if (softwareBitmap !== bitmap) {
|
||||
softwareBitmap.recycle()
|
||||
}
|
||||
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** 辅助:构造 WritableNativeMap。 */
|
||||
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
|
||||
val map = WritableNativeMap()
|
||||
for ((k, v) in pairs) {
|
||||
when (v) {
|
||||
is String -> map.putString(k, v)
|
||||
is Int -> map.putInt(k, v)
|
||||
is Long -> map.putDouble(k, v.toDouble())
|
||||
is Boolean -> map.putBoolean(k, v)
|
||||
is Number -> map.putDouble(k, v.toDouble())
|
||||
is WritableNativeMap -> map.putMap(k, v)
|
||||
is WritableNativeArray -> map.putArray(k, v)
|
||||
else -> map.putNull(k)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** 将当前应用拉到前台以显示确认弹窗 */
|
||||
fun bringAppToForeground() {
|
||||
try {
|
||||
val intent = packageManager.getLaunchIntentForPackage(packageName)
|
||||
if (intent != null) {
|
||||
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED)
|
||||
startActivity(intent)
|
||||
Log.i(TAG, "已成功拉起本应用到前台显示弹窗")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "拉起应用到前台失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归提取无障碍节点树的全部文本。 */
|
||||
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
|
||||
if (node == null) return
|
||||
val text = node.text?.toString()
|
||||
if (!text.isNullOrBlank()) {
|
||||
list.add(text)
|
||||
}
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
dumpNodeTexts(child, list)
|
||||
child.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
|
||||
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
|
||||
if (node == null) return false
|
||||
val text = node.text?.toString()
|
||||
if (text != null) {
|
||||
for (keyword in keywords) {
|
||||
if (text.contains(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
val childCount = node.childCount
|
||||
for (i in 0 until childCount) {
|
||||
val child = node.getChild(i) ?: continue
|
||||
val found = findTextInNode(child, keywords)
|
||||
child.recycle()
|
||||
if (found) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onInterrupt() {
|
||||
Log.w(TAG, "无障碍服务被中断")
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: android.content.Intent?): Boolean {
|
||||
floatingHelper?.dismiss()
|
||||
floatingHelper = null
|
||||
instance = null
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RN 上下文持有者(由 MainApplication 注入)。
|
||||
* 无障碍服务运行在系统进程,需通过静态引用访问 RN 上下文以发送事件。
|
||||
*/
|
||||
object ReactContextHolder {
|
||||
@Volatile var context: ReactApplicationContext? = null
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
import android.widget.EditText
|
||||
import android.widget.HorizontalScrollView
|
||||
import android.widget.LinearLayout
|
||||
import android.text.InputType
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
|
||||
/**
|
||||
* 浮窗账单提示(直接呈现高度优化、支持三方向切换的修改入账面板)。
|
||||
*/
|
||||
class FloatingBillView(
|
||||
private val context: Context,
|
||||
private val draftId: String,
|
||||
private val amount: String,
|
||||
private val merchant: String,
|
||||
private val time: String,
|
||||
private val packageName: String,
|
||||
private val categories: List<Map<String, String>> = emptyList(),
|
||||
private val accounts: List<String> = emptyList(),
|
||||
private val initialDirection: String = "expense"
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingBillView"
|
||||
}
|
||||
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var view: View? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var selectedCategoryAccount: String = ""
|
||||
private var selectedSourceAccount: String = ""
|
||||
private var currentDirection: String = "expense"
|
||||
|
||||
private var categoryLabel: TextView? = null
|
||||
private var categoryContainer: LinearLayout? = null
|
||||
private var accountLabel: TextView? = null
|
||||
private var accountContainer: LinearLayout? = null
|
||||
private var saveBtn: Button? = null
|
||||
|
||||
private fun dp(value: Float): Int {
|
||||
val density = context.resources.displayMetrics.density
|
||||
return (value * density).toInt()
|
||||
}
|
||||
|
||||
private fun createChipView(text: String): TextView {
|
||||
return TextView(context).apply {
|
||||
this.text = text
|
||||
textSize = 11f
|
||||
setPadding(dp(8f), dp(4f), dp(8f), dp(4f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
rightMargin = dp(6f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 显示悬浮窗。 */
|
||||
fun show() {
|
||||
try {
|
||||
currentDirection = if (initialDirection == "income" || initialDirection == "transfer") initialDirection else "expense"
|
||||
|
||||
// 1. 设置 Window 布局参数使其可聚焦(用以键盘输入)并贴靠屏幕底部
|
||||
val windowParams = WindowManager.LayoutParams(
|
||||
dp(310f),
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
0, // 0 标志代表可获取焦点
|
||||
PixelFormat.TRANSLUCENT
|
||||
).apply {
|
||||
gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||
y = dp(12f) // 尽量靠下以留出上方账单对照区
|
||||
}
|
||||
|
||||
// 2. 使用 55% 透明度 OLED 磨砂效果背景,发光靛蓝细边框
|
||||
val containerBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(14f).toFloat()
|
||||
setColor(0x8C050506.toInt()) // 55% 透明度 OLED 黑色
|
||||
setStroke(dp(1.2f), 0x995E6AD2.toInt()) // 60% 透明度靛蓝发光边框
|
||||
}
|
||||
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = containerBg
|
||||
setPadding(dp(12f), dp(8f), dp(12f), dp(8f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
// 顶部标题与方向选择器布局
|
||||
val headerLayout = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
val titleText = TextView(context).apply {
|
||||
text = "调整交易草稿"
|
||||
textSize = 12f
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
paint.isFakeBoldText = true
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
headerLayout.addView(titleText)
|
||||
|
||||
// 三方向分段选择器 (支出 / 收入 / 转账)
|
||||
val segmentContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
background = GradientDrawable().apply {
|
||||
cornerRadius = dp(4f).toFloat()
|
||||
setColor(0x20FFFFFF.toInt()) // 12% white opacity
|
||||
}
|
||||
}
|
||||
|
||||
val tabTexts = listOf("支出", "收入", "转账")
|
||||
val tabDirections = listOf("expense", "income", "transfer")
|
||||
val tabViews = mutableListOf<TextView>()
|
||||
|
||||
fun updateTabStyle() {
|
||||
for (i in tabViews.indices) {
|
||||
val active = tabDirections[i] == currentDirection
|
||||
tabViews[i].apply {
|
||||
setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
background = if (active) {
|
||||
GradientDrawable().apply {
|
||||
cornerRadius = dp(4f).toFloat()
|
||||
setColor(0xFF5E6AD2.toInt()) // Indigo highlight
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i in tabTexts.indices) {
|
||||
val tab = TextView(context).apply {
|
||||
text = tabTexts[i]
|
||||
textSize = 10f
|
||||
setPadding(dp(6f), dp(3f), dp(6f), dp(3f))
|
||||
gravity = Gravity.CENTER
|
||||
setOnClickListener {
|
||||
if (currentDirection != tabDirections[i]) {
|
||||
currentDirection = tabDirections[i]
|
||||
updateTabStyle()
|
||||
rebuildChips()
|
||||
}
|
||||
}
|
||||
}
|
||||
segmentContainer.addView(tab)
|
||||
tabViews.add(tab)
|
||||
}
|
||||
updateTabStyle()
|
||||
headerLayout.addView(segmentContainer)
|
||||
container.addView(headerLayout)
|
||||
|
||||
// 金额编辑
|
||||
val amountLabel = TextView(context).apply {
|
||||
text = "金额"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, dp(4f), 0, dp(1f))
|
||||
}
|
||||
container.addView(amountLabel)
|
||||
|
||||
val amountInput = EditText(context).apply {
|
||||
textSize = 13f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt()) // 25% 半透底色
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(amountInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
||||
|
||||
// 商户编辑
|
||||
val merchantLabel = TextView(context).apply {
|
||||
text = "交易对手"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(1f))
|
||||
}
|
||||
container.addView(merchantLabel)
|
||||
|
||||
val merchantInput = EditText(context).apply {
|
||||
setText(merchant)
|
||||
textSize = 12f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt())
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(merchantInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(3f)) })
|
||||
|
||||
// 叙述备注编辑
|
||||
val narrationLabel = TextView(context).apply {
|
||||
text = "描述/备注"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(1f))
|
||||
}
|
||||
container.addView(narrationLabel)
|
||||
|
||||
val narrationInput = EditText(context).apply {
|
||||
hint = "输入交易叙述"
|
||||
setHintTextColor(0xFF6B7280.toInt())
|
||||
textSize = 12f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
setColor(0x4012131A.toInt())
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
setPadding(dp(10f), dp(4f), dp(10f), dp(4f))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(narrationInput)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
|
||||
// Row 1 分类/转入选择
|
||||
categoryLabel = TextView(context).apply {
|
||||
text = "交易分类"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(2f))
|
||||
}
|
||||
container.addView(categoryLabel)
|
||||
|
||||
categoryContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
}
|
||||
|
||||
val categoryScroll = HorizontalScrollView(context).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
addView(categoryContainer)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(categoryScroll)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(4f)) })
|
||||
|
||||
// Row 2 资金出入账户选择
|
||||
accountLabel = TextView(context).apply {
|
||||
text = "资金来源账户"
|
||||
textSize = 9f
|
||||
paint.isFakeBoldText = true
|
||||
setTextColor(0xFF98A2FF.toInt())
|
||||
setPadding(0, 0, 0, dp(2f))
|
||||
}
|
||||
container.addView(accountLabel)
|
||||
|
||||
accountContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
}
|
||||
|
||||
val accountScroll = HorizontalScrollView(context).apply {
|
||||
isHorizontalScrollBarEnabled = false
|
||||
addView(accountContainer)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
container.addView(accountScroll)
|
||||
container.addView(View(context).apply { layoutParams = LinearLayout.LayoutParams(1, dp(8f)) })
|
||||
|
||||
// 底部操作栏
|
||||
val btnContainer = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
|
||||
// 1) 打开应用按钮
|
||||
val openAppBtn = Button(context).apply {
|
||||
text = "打开应用"
|
||||
setTextColor(0xFFD1D5DB.toInt())
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF1F2937.toInt())
|
||||
setStroke(dp(1f), 0xFF374151.toInt())
|
||||
}
|
||||
textSize = 11f
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
||||
setOnClickListener {
|
||||
val newAmount = amountInput.text.toString().trim()
|
||||
val newPayee = merchantInput.text.toString().trim()
|
||||
val newNarration = narrationInput.text.toString().trim()
|
||||
|
||||
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
||||
dismiss()
|
||||
BillingAccessibilityService.instance?.bringAppToForeground()
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 忽略按钮
|
||||
val cancelBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF1F2937.toInt())
|
||||
setStroke(dp(1f), 0xFF374151.toInt())
|
||||
}
|
||||
val cancelBtn = Button(context).apply {
|
||||
text = "忽略"
|
||||
setTextColor(0xFFD1D5DB.toInt())
|
||||
background = cancelBg
|
||||
textSize = 11f
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1f).apply { rightMargin = dp(6f) }
|
||||
setOnClickListener {
|
||||
sendCancelEvent()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 确认入账按钮
|
||||
val saveBg = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(0xFF5E6AD2.toInt())
|
||||
}
|
||||
saveBtn = Button(context).apply {
|
||||
text = "确认入账"
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
background = saveBg
|
||||
textSize = 11f
|
||||
paint.isFakeBoldText = true
|
||||
layoutParams = LinearLayout.LayoutParams(0, dp(32f), 1.3f)
|
||||
setOnClickListener {
|
||||
val newAmount = amountInput.text.toString().trim()
|
||||
val newPayee = merchantInput.text.toString().trim()
|
||||
val newNarration = narrationInput.text.toString().trim()
|
||||
|
||||
sendSaveEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
btnContainer.addView(openAppBtn)
|
||||
btnContainer.addView(cancelBtn)
|
||||
btnContainer.addView(saveBtn)
|
||||
container.addView(btnContainer)
|
||||
|
||||
// 构建金额安全校验
|
||||
val amountWatcher = object : android.text.TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: android.text.Editable?) {
|
||||
try {
|
||||
val text = s?.toString()?.trim() ?: ""
|
||||
val value = text.toDoubleOrNull()
|
||||
val isValid = value != null && !value.isNaN() && !value.isInfinite() && value > 0.0
|
||||
saveBtn?.isEnabled = isValid
|
||||
saveBtn?.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(8f).toFloat()
|
||||
setColor(if (isValid) 0xFF5E6AD2.toInt() else 0xFF374151.toInt())
|
||||
}
|
||||
saveBtn?.setTextColor(if (isValid) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "金额校验异常", e)
|
||||
saveBtn?.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
amountInput.addTextChangedListener(amountWatcher)
|
||||
|
||||
// 填充金额触发 Watcher 校验
|
||||
amountInput.setText(amount)
|
||||
|
||||
// 动态初始构建两行胶囊
|
||||
rebuildChips()
|
||||
|
||||
view = container
|
||||
windowManager.addView(view, windowParams)
|
||||
Log.i(TAG, "悬浮修改记账面板已显示: ¥$amount")
|
||||
|
||||
// 用户一旦进行任何交互(触摸面板或获得输入焦点),立刻取消自动消失定时器
|
||||
val cancelTimerListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
Log.d(TAG, "已获得输入焦点,取消自动消失定时器")
|
||||
}
|
||||
}
|
||||
amountInput.onFocusChangeListener = cancelTimerListener
|
||||
merchantInput.onFocusChangeListener = cancelTimerListener
|
||||
narrationInput.onFocusChangeListener = cancelTimerListener
|
||||
|
||||
container.setOnTouchListener { _, _ ->
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
Log.d(TAG, "已触摸卡片,取消自动消失定时器")
|
||||
false
|
||||
}
|
||||
|
||||
// 15 秒无操作自动消失(如果用户没有交互的话)
|
||||
handler.postDelayed({
|
||||
sendCancelEvent()
|
||||
dismiss()
|
||||
}, 15000L)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "悬浮账单面板显示失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 动态根据交易方向重绘第一行与第二行滑动的胶囊列表 */
|
||||
private fun rebuildChips() {
|
||||
categoryContainer?.removeAllViews()
|
||||
accountContainer?.removeAllViews()
|
||||
|
||||
val categoryChips = mutableListOf<Pair<String, TextView>>()
|
||||
val accountChips = mutableListOf<Pair<String, TextView>>()
|
||||
|
||||
// === 1. 绘制第一行 (分类 / 转入) ===
|
||||
if (currentDirection == "expense") {
|
||||
categoryLabel?.text = "交易分类"
|
||||
val filteredCats = categories.filter { it["type"] == "expense" }
|
||||
for (cat in filteredCats) {
|
||||
val catAccount = cat["account"] ?: ""
|
||||
val catName = cat["name"] ?: ""
|
||||
val chip = createChipView(catName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedCategoryAccount = catAccount
|
||||
updateStyle(catAccount)
|
||||
}
|
||||
categoryContainer?.addView(chip)
|
||||
categoryChips.add(catAccount to chip)
|
||||
}
|
||||
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
|
||||
} else if (currentDirection == "income") {
|
||||
categoryLabel?.text = "收入分类"
|
||||
val filteredCats = categories.filter { it["type"] == "income" }
|
||||
for (cat in filteredCats) {
|
||||
val catAccount = cat["account"] ?: ""
|
||||
val catName = cat["name"] ?: ""
|
||||
val chip = createChipView(catName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedCategoryAccount = catAccount
|
||||
updateStyle(catAccount)
|
||||
}
|
||||
categoryContainer?.addView(chip)
|
||||
categoryChips.add(catAccount to chip)
|
||||
}
|
||||
selectedCategoryAccount = filteredCats.firstOrNull()?.get("account") ?: ""
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF5E6AD2.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
|
||||
} else {
|
||||
// transfer
|
||||
categoryLabel?.text = "转入账户"
|
||||
for (acct in accounts) {
|
||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||
val chip = createChipView(shortName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in categoryChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF10B981.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedCategoryAccount = acct
|
||||
updateStyle(acct)
|
||||
}
|
||||
categoryContainer?.addView(chip)
|
||||
categoryChips.add(acct to chip)
|
||||
}
|
||||
selectedCategoryAccount = if (accounts.size > 1 && accounts[0] == selectedSourceAccount) accounts[1] else (accounts.firstOrNull() ?: "")
|
||||
categoryChips.forEach { pair ->
|
||||
val active = pair.first == selectedCategoryAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) setColor(0xFF10B981.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
// === 2. 绘制第二行 (资金账户) ===
|
||||
if (currentDirection == "expense") {
|
||||
accountLabel?.text = "资金来源"
|
||||
} else if (currentDirection == "income") {
|
||||
accountLabel?.text = "存入账户"
|
||||
} else {
|
||||
accountLabel?.text = "转出账户"
|
||||
}
|
||||
|
||||
for (acct in accounts) {
|
||||
val shortName = acct.split(":").lastOrNull() ?: acct
|
||||
val chip = createChipView(shortName)
|
||||
|
||||
fun updateStyle(selected: String) {
|
||||
for (pair in accountChips) {
|
||||
val active = pair.first == selected
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) {
|
||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
||||
} else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
chip.setOnClickListener {
|
||||
selectedSourceAccount = acct
|
||||
updateStyle(acct)
|
||||
|
||||
// 若是转账,且转入转出账户冲突,自动移开转入账户
|
||||
if (currentDirection == "transfer" && selectedCategoryAccount == selectedSourceAccount) {
|
||||
val nextAvail = categoryChips.find { it.first != selectedSourceAccount }
|
||||
if (nextAvail != null) {
|
||||
selectedCategoryAccount = nextAvail.first
|
||||
for (p in categoryChips) {
|
||||
val act = p.first == selectedCategoryAccount
|
||||
p.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (act) setColor(0xFF10B981.toInt())
|
||||
else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
p.second.setTextColor(if (act) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accountContainer?.addView(chip)
|
||||
accountChips.add(acct to chip)
|
||||
}
|
||||
|
||||
selectedSourceAccount = accounts.firstOrNull() ?: ""
|
||||
accountChips.forEach { pair ->
|
||||
val active = pair.first == selectedSourceAccount
|
||||
pair.second.background = GradientDrawable().apply {
|
||||
cornerRadius = dp(6f).toFloat()
|
||||
if (active) {
|
||||
setColor(if (currentDirection == "income") 0xFF10B981.toInt() else 0xFFE11D48.toInt())
|
||||
} else {
|
||||
setColor(0x4012131A.toInt())
|
||||
setStroke(dp(1f), 0x80222433.toInt())
|
||||
}
|
||||
}
|
||||
pair.second.setTextColor(if (active) 0xFFFFFFFF.toInt() else 0xFF9CA3AF.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送保存事件到 JS 层。 */
|
||||
private fun sendSaveEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("draftId", draftId)
|
||||
putString("amount", newAmount)
|
||||
putString("merchant", newPayee)
|
||||
putString("narration", newNarration)
|
||||
putString("category", categoryAccount)
|
||||
putString("account", sourceAccount)
|
||||
putString("time", time)
|
||||
putString("direction", currentDirection)
|
||||
putString("packageName", packageName)
|
||||
putBoolean("confirmed", true)
|
||||
putBoolean("editRequested", false)
|
||||
putBoolean("isManualEdit", true)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingConfirmed", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送修改保存事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送打开应用事件到 JS 层,以便前台加载详细表单。 */
|
||||
private fun sendOpenAppEvent(newAmount: String, newPayee: String, newNarration: String, categoryAccount: String, sourceAccount: String) {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("draftId", draftId)
|
||||
putString("amount", newAmount)
|
||||
putString("merchant", newPayee)
|
||||
putString("narration", newNarration)
|
||||
putString("category", categoryAccount)
|
||||
putString("account", sourceAccount)
|
||||
putString("time", time)
|
||||
putString("direction", currentDirection)
|
||||
putString("packageName", packageName)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingOpenApp", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送打开应用事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 推送取消/忽略事件到 JS 层。 */
|
||||
private fun sendCancelEvent() {
|
||||
val reactContext = ReactContextHolder.context ?: return
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("draftId", draftId)
|
||||
putBoolean("confirmed", false)
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingConfirmed", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送取消事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭浮窗。 */
|
||||
fun dismiss() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
try {
|
||||
view?.let { windowManager.removeView(it) }
|
||||
} catch (_: Exception) {}
|
||||
view = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
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 = 0
|
||||
} else {
|
||||
params.x = service.resources.displayMetrics.widthPixels - dp(24f)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.PixelFormat
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* 浮窗提示(plan.md「3.8 浮窗账单提示」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 FloatingTip + RepeatToast:
|
||||
* - 轻量浮窗(非全屏),滑入动画,自动消失
|
||||
* - 三种布局:顶部 / 左侧 / 右侧
|
||||
* - 倒计时进度环
|
||||
* - 重复账单提示(RepeatToast)
|
||||
*
|
||||
* 比 FloatingBillView 更轻:仅展示提示,不交互。
|
||||
* 需 SYSTEM_ALERT_WINDOW 权限(由 Config Plugin 注册)。
|
||||
*/
|
||||
class FloatingTip(
|
||||
private val context: Context,
|
||||
private val message: String,
|
||||
private val position: TipPosition = TipPosition.TOP,
|
||||
private val durationMs: Long = 3000L,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingTip"
|
||||
private const val ANIM_DURATION = 300L
|
||||
}
|
||||
|
||||
enum class TipPosition { TOP, LEFT, RIGHT }
|
||||
|
||||
private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
|
||||
private var view: View? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
/** 显示浮窗提示。 */
|
||||
fun show() {
|
||||
try {
|
||||
val layoutParams = WindowManager.LayoutParams(
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.WRAP_CONTENT,
|
||||
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
|
||||
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
|
||||
PixelFormat.TRANSLUCENT,
|
||||
).apply {
|
||||
gravity = when (position) {
|
||||
TipPosition.TOP -> Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
TipPosition.LEFT -> Gravity.LEFT or Gravity.CENTER_VERTICAL
|
||||
TipPosition.RIGHT -> Gravity.RIGHT or Gravity.CENTER_VERTICAL
|
||||
}
|
||||
y = if (position == TipPosition.TOP) 100 else 0
|
||||
x = if (position != TipPosition.TOP) 50 else 0
|
||||
}
|
||||
|
||||
val container = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
setBackgroundColor(0xF0333333.toInt())
|
||||
setPadding(32, 16, 32, 16)
|
||||
}
|
||||
val text = TextView(context).apply {
|
||||
text = message
|
||||
textSize = 13f
|
||||
setTextColor(0xFFFFFFFF.toInt())
|
||||
}
|
||||
// 倒计时进度条
|
||||
val progress = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal)
|
||||
progress.max = 100
|
||||
progress.progress = 100
|
||||
progress.layoutParams = LinearLayout.LayoutParams(200, 8).apply {
|
||||
setMargins(16, 0, 0, 0)
|
||||
}
|
||||
container.addView(text)
|
||||
container.addView(progress)
|
||||
view = container
|
||||
|
||||
windowManager.addView(view, layoutParams)
|
||||
Log.d(TAG, "FloatingTip 显示: $message")
|
||||
|
||||
// 倒计时动画(300ms 内 progress 从 100 到 0)
|
||||
val steps = 30
|
||||
val stepMs = durationMs / steps
|
||||
var currentStep = 0
|
||||
handler.post(object : Runnable {
|
||||
override fun run() {
|
||||
currentStep++
|
||||
progress.progress = 100 - (currentStep * 100 / steps)
|
||||
if (currentStep < steps) {
|
||||
handler.postDelayed(this, stepMs)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
handler.postDelayed({ dismiss() }, durationMs)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "FloatingTip 显示失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭浮窗。 */
|
||||
fun dismiss() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
try { view?.let { windowManager.removeView(it) } } catch (_: Exception) {}
|
||||
view = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复账单提示(plan.md「3.8」+ AutoAccounting RepeatToast)。
|
||||
* 当检测到重复账单时,轻量提示用户(不弹浮窗,用系统 Toast 风格)。
|
||||
*/
|
||||
class RepeatToast(private val context: Context, private val message: String) {
|
||||
fun show() {
|
||||
val tip = FloatingTip(context, "⚠ $message", FloatingTip.TipPosition.TOP, 2000L)
|
||||
tip.show()
|
||||
Log.d("RepeatToast", "重复提示: $message")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.beancount.mobile.accessibility
|
||||
|
||||
import android.os.Build
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import android.util.Log
|
||||
import android.content.Intent
|
||||
import android.app.PendingIntent
|
||||
|
||||
/**
|
||||
* 快速设置磁贴(plan.md「3.11 快速设置磁贴」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 OcrTileService:
|
||||
* - 用户下拉快速设置,点击「OCR 记账」磁贴触发一次手动 OCR
|
||||
* - Android 14+ 用 PendingIntent + startActivityAndCollapse
|
||||
*
|
||||
* 触发后调用 BillingAccessibilityService.triggerManualOcr()。
|
||||
*/
|
||||
class OcrTileService : TileService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "OcrTileService"
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
super.onStartListening()
|
||||
qsTile?.let { tile ->
|
||||
tile.state = Tile.STATE_ACTIVE
|
||||
tile.label = "OCR 记账"
|
||||
tile.updateTile()
|
||||
}
|
||||
Log.d(TAG, "磁贴开始监听")
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
super.onClick()
|
||||
Log.i(TAG, "磁贴被点击,触发手动 OCR")
|
||||
triggerManualOcr()
|
||||
}
|
||||
|
||||
/** 触发手动 OCR(通过 BillingAccessibilityService)。 */
|
||||
private fun triggerManualOcr() {
|
||||
val service = BillingAccessibilityService.instance
|
||||
if (service != null) {
|
||||
service.triggerManualOcr()
|
||||
return
|
||||
}
|
||||
// 服务未运行,尝试启动(Android 14+ 用 collapse)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
Intent().apply {
|
||||
setClassName(packageName, "com.beancount.mobile.MainActivity")
|
||||
action = "com.beancount.mobile.TRIGGER_OCR"
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
},
|
||||
PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
startActivityAndCollapse(pi)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
无障碍服务配置(plan.md「3.6 无障碍服务」)。
|
||||
注册监听支付 App 的页面变化 + 截图能力。
|
||||
canTakeScreenshot="true" 是 Android 11+ AccessibilityService.takeScreenshot() 的前提。
|
||||
-->
|
||||
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:description="@string/accessibility_service_description"
|
||||
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
|
||||
android:accessibilityFeedbackType="feedbackGeneric"
|
||||
android:notificationTimeout="100"
|
||||
android:canRequestEnhancedWebAccessibility="true"
|
||||
android:canRetrieveWindowContent="true"
|
||||
android:canTakeScreenshot="true"
|
||||
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows" />
|
||||
@@ -62,44 +62,16 @@ function withAccessibilityService(config) {
|
||||
},
|
||||
]);
|
||||
|
||||
// 2. 注入 ReactContextHolder 赋值 + 注册 AccessibilityBridgePackage 到 MainApplication
|
||||
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
|
||||
config = withMainApplication(config, (modConfig) => {
|
||||
let content = modConfig.modResults.contents;
|
||||
|
||||
// 2a. 注入 import(ReactContextHolder + AccessibilityBridgePackage)
|
||||
if (!content.includes(`import ${PACKAGE}.ReactContextHolder`)) {
|
||||
// 2a. 注入 import(AccessibilityBridgePackage)
|
||||
if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
|
||||
content = content.replace(
|
||||
/^(package\s+[\w.]+;?\s*)$/m,
|
||||
`$1\nimport ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||
);
|
||||
} else if (!content.includes(`import ${PACKAGE}.AccessibilityBridgePackage`)) {
|
||||
content = content.replace(
|
||||
`import ${PACKAGE}.ReactContextHolder`,
|
||||
`import ${PACKAGE}.ReactContextHolder\nimport ${PACKAGE}.AccessibilityBridgePackage`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2b. 在 onCreate 方法中注入 ReactContextHolder.context = this
|
||||
if (!content.includes('ReactContextHolder.context')) {
|
||||
// 尝试在 super.onCreate() 后注入
|
||||
if (/super\.onCreate\(\)/.test(content)) {
|
||||
content = content.replace(
|
||||
/(super\.onCreate\(\))/,
|
||||
`$1\n // 由 Config Plugin 注入:让原生服务能访问 RN 上下文\n ReactContextHolder.context = this`,
|
||||
);
|
||||
} else if (/onCreate/.test(content)) {
|
||||
// 有 onCreate 但没有 super.onCreate()(不太可能)
|
||||
content = content.replace(
|
||||
/(onCreate[^{]*\{)/,
|
||||
`$1\n ReactContextHolder.context = this`,
|
||||
);
|
||||
} else {
|
||||
// 没有 onCreate — 在类体内注入一个
|
||||
content = content.replace(
|
||||
/(class\s+MainApplication\s*[^{]*\{)/,
|
||||
`$1\n override fun onCreate() {\n super.onCreate()\n ReactContextHolder.context = this\n }`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.beancount.mobile.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.ComponentName
|
||||
import android.service.notification.NotificationListenerService
|
||||
import android.service.notification.StatusBarNotification
|
||||
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.ReactContextHolder
|
||||
|
||||
/**
|
||||
* 通知监听服务(plan.md「4.1 通知监听服务」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 NotificationListenerService:
|
||||
* - 提取支付 App 通知的 title/text
|
||||
* - 白名单过滤(仅支付类 App)
|
||||
* - 关键词黑白名单(JS 层 keywordFilter 进一步过滤)
|
||||
* - MD5 去重(JS 层 NotificationChannel 处理,避免原生持有状态)
|
||||
* - onListenerDisconnected 时 requestRebind 自动重连
|
||||
*
|
||||
* 通过 DeviceEventEmitter 把通知事件推送到 JS 层 NotificationChannel。
|
||||
*/
|
||||
class BillingNotificationListenerService : NotificationListenerService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingNotification"
|
||||
|
||||
/** 支付 App 白名单(与 JS 层 DEFAULT_PAYMENT_PACKAGES 一致)。 */
|
||||
private val PAYMENT_PACKAGES = setOf(
|
||||
"com.eg.android.AlipayGphone",
|
||||
"com.tencent.mm",
|
||||
"com.unionpay",
|
||||
"com.cmbchina",
|
||||
"com.icbc",
|
||||
"com.chinamworld.main",
|
||||
"com.ccbrcb",
|
||||
"com.bankcomm.Bankcomm",
|
||||
)
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
super.onNotificationPosted(sbn)
|
||||
runCatching {
|
||||
val packageName = sbn?.packageName?.toString() ?: return
|
||||
// 白名单过滤
|
||||
if (!PAYMENT_PACKAGES.contains(packageName)) return
|
||||
|
||||
val notification = sbn.notification
|
||||
val extras = notification.extras
|
||||
val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() ?: ""
|
||||
val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)
|
||||
?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString() ?: ""
|
||||
|
||||
if (title.isBlank() && text.isBlank()) return
|
||||
|
||||
Log.d(TAG, "收到支付通知: pkg=$packageName, title=$title")
|
||||
// 推送到 JS 层
|
||||
sendNotificationEvent(packageName, title, text)
|
||||
}.onFailure {
|
||||
Log.e(TAG, "通知处理异常: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听断开时自动重连(参考 AutoAccounting requestRebind)。
|
||||
* Android Doze / App Standby 可能断开通知监听。
|
||||
*/
|
||||
override fun onListenerDisconnected() {
|
||||
super.onListenerDisconnected()
|
||||
Log.w(TAG, "通知监听断开,尝试重连")
|
||||
requestRebind(ComponentName(this, BillingNotificationListenerService::class.java))
|
||||
}
|
||||
|
||||
/** 把通知事件推送到 JS 层 NotificationChannel.handleNotification。 */
|
||||
private fun sendNotificationEvent(packageName: String, title: String, text: String) {
|
||||
val reactContext = ReactContextHolder.context ?: run {
|
||||
Log.w(TAG, "RN 上下文未就绪,丢弃通知")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("packageName", packageName)
|
||||
putString("title", title)
|
||||
putString("text", text)
|
||||
putDouble("timestamp", System.currentTimeMillis().toDouble())
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingNotification", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送通知事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-5
@@ -14,8 +14,8 @@ plugins/ppocr/
|
||||
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages)
|
||||
└── assets/ # ONNX 模型 + 字典(需自行下载放置)
|
||||
├── ppocrv5_det.onnx # 文本检测模型
|
||||
├── ppocrv5_rec.onnx # 文本识别模型
|
||||
└── ppocr_keys_v1.txt # CJK 字典(CTC 解码用)
|
||||
├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维)
|
||||
└── ppocrv5_dict.txt # PP-OCRv5 多语言字典(18383 字符,CTC 解码用)
|
||||
```
|
||||
|
||||
## 模型获取(一键下载)
|
||||
@@ -33,12 +33,16 @@ curl -L -o ppocrv5_det.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/
|
||||
# rec 模型(16.6 MB)
|
||||
curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx
|
||||
|
||||
# CJK 字典(26 KB,PaddleOCR 标准字典)
|
||||
curl -L -o ppocr_keys_v1.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/release/2.6/ppocr/utils/ppocr_keys_v1.txt
|
||||
# PP-OCRv5 多语言字典(74 KB,必须与上面的 rec 模型配套)
|
||||
curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt
|
||||
```
|
||||
|
||||
或用 HuggingFace CLI(首次下载原生模型再转 ONNX 的方式,参见历史 git log)。
|
||||
|
||||
> ⚠️ **字典必须与 rec 模型配套**:ppocrv5_rec.onnx 输出 18385 维(= 18383 字符 + blank + 特殊位),
|
||||
> 必须使用 `ppocrv5_dict.txt`(18383 行)。若错用旧版 `ppocr_keys_v1.txt`(仅 6623 行),
|
||||
> CTC 解码会把真实字符的高索引全部丢弃,只输出形如 `'消'青'露'仰'` 的单引号穿插单字符乱码。
|
||||
|
||||
> 来源说明:[ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 是社区维护的 PP-OCRv5 mobile ONNX 镜像,基于官方 [PaddlePaddle/PP-OCRv5_mobile_det](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det) 与 [_rec](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_rec) 转换而来。
|
||||
|
||||
## 性能配置(参考 AutoAccounting OcrProcessor.kt)
|
||||
@@ -75,4 +79,6 @@ JS 层通过 `src/services/ocrBridge.ts` 的 `NativeOcrBridge` 调用,桥接
|
||||
- `android/OcrPackage.kt`:✅ RN Package 注册
|
||||
- `assets/`:需自行下载放置(见上「模型获取」),版权/体积原因不入仓库
|
||||
|
||||
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android` → `npx expo run:android`。
|
||||
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`(Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`。
|
||||
|
||||
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧字典(如 `ppocr_keys_v1.txt`),导致新代码找不到配套字典。
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
package com.beancount.mobile.ppocr
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import ai.onnxruntime.OnnxTensor
|
||||
import ai.onnxruntime.OrtEnvironment
|
||||
import ai.onnxruntime.OrtSession
|
||||
import com.facebook.react.bridge.Arguments
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableArray
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.bridge.WritableNativeArray
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.module.annotations.ReactModule
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.nio.FloatBuffer
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* PP-OCRv5 (ONNX Runtime) React Native Bridge(plan.md「3.4 Layer 2」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 引擎:ONNX Runtime(跨平台、微软官方、Windows 友好),替代 NCNN 路线。
|
||||
* 模型:ppocrv5_det.onnx + ppocrv5_rec.onnx(从 ilaylow/PP_OCRv5_mobile_onnx 下载)。
|
||||
* 字典:ppocrv5_dict.txt(PP-OCRv5 多语言字典,18383 字符;rec 模型 18385 维输出 = 字典 + blank + 特殊位)。
|
||||
*
|
||||
* 流水线:
|
||||
* 1. det(文本检测):bitmap → DB 后处理得到文本框
|
||||
* 2. rec(文本识别):每个框 crop → resize 到 48px 高 → CTC 解码
|
||||
*
|
||||
* 性能优化(参考 AutoAccounting OcrProcessor.kt):
|
||||
* - 短边压缩到 720px(像素量比 1440p 减少约 75%)
|
||||
* - CPU 执行(兼容性最稳,部分设备 GPU 会崩溃)
|
||||
* - det 最大边限制 960(PaddleOCR 默认 limit_max_side_len)
|
||||
*
|
||||
* JS 层通过 NativeModules.PpOcr.recognizeText(base64) 调用。
|
||||
*/
|
||||
const val OCR_MODULE_NAME = "PpOcr"
|
||||
|
||||
@ReactModule(name = OCR_MODULE_NAME)
|
||||
class OcrModule(private val context: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(context) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val lock = ReentrantLock()
|
||||
|
||||
private var ortEnv: OrtEnvironment? = null
|
||||
private var detSession: OrtSession? = null
|
||||
private var recSession: OrtSession? = null
|
||||
private var dictionary: List<String> = emptyList()
|
||||
@Volatile private var initialized = false
|
||||
@Volatile private var initFailed = false
|
||||
|
||||
override fun getName(): String = OCR_MODULE_NAME
|
||||
|
||||
override fun initialize() {
|
||||
super.initialize()
|
||||
// 异步加载模型,避免阻塞 RN 桥初始化
|
||||
scope.launch { initEngine() }
|
||||
}
|
||||
|
||||
/** 从 assets 加载 det/rec ONNX 模型与字典。 */
|
||||
private fun initEngine() {
|
||||
lock.lock()
|
||||
try {
|
||||
if (initialized || initFailed) return
|
||||
val env = OrtEnvironment.getEnvironment()
|
||||
val opts = OrtSession.SessionOptions().apply {
|
||||
// CPU 线程数:2 是兼容性/性能的稳妥折中(高端机可调高)
|
||||
setInterOpNumThreads(2)
|
||||
setIntraOpNumThreads(2)
|
||||
// 移动端关闭内存优化里的图优化级别过高(部分模型会崩)
|
||||
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.BASIC_OPT)
|
||||
}
|
||||
val detBytes = context.assets.open(ASSET_DET_MODEL).use { it.readBytes() }
|
||||
val recBytes = context.assets.open(ASSET_REC_MODEL).use { it.readBytes() }
|
||||
val det = env.createSession(detBytes, opts)
|
||||
val rec = env.createSession(recBytes, opts)
|
||||
val dict = loadDictionary()
|
||||
|
||||
detSession = det
|
||||
recSession = rec
|
||||
ortEnv = env
|
||||
dictionary = dict
|
||||
initialized = true
|
||||
Log.i(OCR_MODULE_NAME, "PP-OCRv5 ONNX 模型加载成功(det+rec, dict=${dict.size})")
|
||||
} catch (e: Exception) {
|
||||
initFailed = true
|
||||
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
|
||||
Log.e(OCR_MODULE_NAME, "请确认 assets 下存在 $ASSET_DET_MODEL / $ASSET_REC_MODEL / $ASSET_DICT")
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/** rec 推理用的 OrtEnvironment(复用 ortEnv 单例)。 */
|
||||
private val recEnv: OrtEnvironment? get() = ortEnv
|
||||
|
||||
/**
|
||||
* 加载 ppocrv5_dict.txt 字典。
|
||||
*
|
||||
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank,字符从 index 1 开始;
|
||||
* 字典条目 dictionary[i] 对应模型输出 index i+1。解码时 dictIdx = argmaxIdx - 1。
|
||||
* 字典本身不含 blank,运行时固定用 index 0 作 blank(见 ctcGreedyDecode)。
|
||||
*/
|
||||
private fun loadDictionary(): List<String> {
|
||||
val words = mutableListOf<String>()
|
||||
context.assets.open(ASSET_DICT).use { stream ->
|
||||
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
// PaddleOCR 字典每行一个字符(去掉行尾换行)
|
||||
words.add(line.trimEnd('\r', '\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别图片文本。
|
||||
* @param imageBase64 base64 编码的图片(JPEG/PNG)
|
||||
* @return 识别出的纯文本(所有行用 \n 连接)
|
||||
*/
|
||||
@ReactMethod
|
||||
fun recognizeText(imageBase64: String, promise: Promise) {
|
||||
scope.launch {
|
||||
var bitmap: Bitmap? = null
|
||||
var scaled: Bitmap? = null
|
||||
try {
|
||||
ensureReady()
|
||||
bitmap = decodeBase64(imageBase64)
|
||||
if (bitmap == null) {
|
||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||
return@launch
|
||||
}
|
||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
||||
val blocks = runInference(scaled)
|
||||
val text = blocks.joinToString("\n") { it.text }
|
||||
promise.resolve(text)
|
||||
} catch (e: Exception) {
|
||||
Log.e(OCR_MODULE_NAME, "recognizeText 异常: ${e.message}", e)
|
||||
promise.reject("OCR_ERROR", e.message)
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
if (scaled !== bitmap) {
|
||||
scaled?.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别并返回带坐标的文本块(用于复杂版面)。
|
||||
* @return JSON 数组字符串:[{text, x, y, width, height, confidence}]
|
||||
*/
|
||||
@ReactMethod
|
||||
fun recognizeTextBlocks(imageBase64: String, promise: Promise) {
|
||||
scope.launch {
|
||||
var bitmap: Bitmap? = null
|
||||
var scaled: Bitmap? = null
|
||||
try {
|
||||
ensureReady()
|
||||
bitmap = decodeBase64(imageBase64)
|
||||
if (bitmap == null) {
|
||||
promise.reject("DECODE_FAILED", "base64 解码失败")
|
||||
return@launch
|
||||
}
|
||||
scaled = scaleDownForOcr(bitmap, OCR_MAX_SHORT_EDGE)
|
||||
val blocks = runInference(scaled)
|
||||
// 序列化为 RN WritableArray
|
||||
val arr = WritableNativeArray()
|
||||
for (b in blocks) {
|
||||
val map: WritableMap = WritableNativeMap()
|
||||
map.putString("text", b.text)
|
||||
map.putDouble("x", b.x.toDouble())
|
||||
map.putDouble("y", b.y.toDouble())
|
||||
map.putDouble("width", b.width.toDouble())
|
||||
map.putDouble("height", b.height.toDouble())
|
||||
map.putDouble("confidence", b.confidence.toDouble())
|
||||
arr.pushMap(map)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
} catch (e: Exception) {
|
||||
Log.e(OCR_MODULE_NAME, "recognizeTextBlocks 异常: ${e.message}", e)
|
||||
promise.reject("OCR_ERROR", e.message)
|
||||
} finally {
|
||||
bitmap?.recycle()
|
||||
if (scaled !== bitmap) {
|
||||
scaled?.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 引擎是否已就绪(模型加载完成)。 */
|
||||
@ReactMethod
|
||||
fun isReady(promise: Promise) {
|
||||
promise.resolve(initialized)
|
||||
}
|
||||
|
||||
// ============== 推理流水线 ==============
|
||||
|
||||
private fun ensureReady() {
|
||||
if (!initialized && !initFailed) initEngine()
|
||||
if (!initialized) throw IllegalStateException("OCR 引擎未就绪(模型未加载,${if (initFailed) "初始化失败" else "加载中"})")
|
||||
}
|
||||
|
||||
/** 完整推理:det 检测文本框 → 对每个框 rec 识别 → 返回带坐标的文本块。 */
|
||||
private fun runInference(bitmap: Bitmap): List<OcrBlock> {
|
||||
Log.i(OCR_MODULE_NAME, "runInference 开始: bitmap 尺寸 = ${bitmap.width}x${bitmap.height}")
|
||||
val det = detSession ?: run {
|
||||
Log.e(OCR_MODULE_NAME, "detSession 为空,放弃推理")
|
||||
return emptyList()
|
||||
}
|
||||
val rec = recSession ?: run {
|
||||
Log.e(OCR_MODULE_NAME, "recSession 为空,放弃推理")
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
var resized: Bitmap? = null
|
||||
var detInputTensor: OnnxTensor? = null
|
||||
var detOutputs: OrtSession.Result? = null
|
||||
val results = mutableListOf<OcrBlock>()
|
||||
|
||||
try {
|
||||
// ---- 1. 文本检测(DB)----
|
||||
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
|
||||
val ratioY = bitmap.height.toFloat() / resized.height
|
||||
|
||||
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)
|
||||
detOutputs = det.run(detInputs)
|
||||
@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}")
|
||||
|
||||
// DB 后处理:threshold → 轮廓 → 最小外接矩形
|
||||
val boxes = dbPostprocess(detProb, detInput.h, detInput.w, ratioX, ratioY)
|
||||
Log.i(OCR_MODULE_NAME, "dbPostprocess 后处理完成,检测到文本框数量 = ${boxes.size}")
|
||||
|
||||
if (boxes.isEmpty()) return emptyList()
|
||||
|
||||
// ---- 2. 文本识别(CRNN+CTC)----
|
||||
for ((idx, box) in boxes.withIndex()) {
|
||||
var crop: Bitmap? = null
|
||||
var recInputTensor: OnnxTensor? = null
|
||||
var recOutputs: OrtSession.Result? = null
|
||||
try {
|
||||
crop = cropBox(bitmap, box)
|
||||
if (crop == null) {
|
||||
Log.w(OCR_MODULE_NAME, "裁剪文本框失败 (index = $idx)")
|
||||
continue
|
||||
}
|
||||
val recInput = preprocessRec(crop)
|
||||
recInputTensor = OnnxTensor.createTensor(recEnv, FloatBuffer.wrap(recInput.data), longArrayOf(1L, 3L, REC_IMAGE_HEIGHT.toLong(), recInput.w.toLong()))
|
||||
val recInputs = mapOf("x" to recInputTensor)
|
||||
recOutputs = rec.run(recInputs)
|
||||
// 输出 shape: [1, T, numClasses]
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val logits = (recOutputs[0].value as Array<Array<FloatArray>>)[0]
|
||||
|
||||
val (text, conf) = ctcGreedyDecode(logits)
|
||||
val xs = box.map { it[0] }
|
||||
val ys = box.map { it[1] }
|
||||
val minX = (xs.minOrNull() ?: 0f).toInt()
|
||||
val minY = (ys.minOrNull() ?: 0f).toInt()
|
||||
val maxX = (xs.maxOrNull() ?: 0f).toInt()
|
||||
val maxY = (ys.maxOrNull() ?: 0f).toInt()
|
||||
val w = maxX - minX
|
||||
val h = maxY - minY
|
||||
Log.i(OCR_MODULE_NAME, "文本框 $idx 识别结果 = '$text', 坐标 = ($minX, $minY, $w, $h), 置信度 = $conf")
|
||||
if (text.isNotEmpty()) {
|
||||
results.add(OcrBlock(text, minX.toFloat(), minY.toFloat(), w.toFloat(), h.toFloat(), conf))
|
||||
}
|
||||
} finally {
|
||||
crop?.recycle()
|
||||
recInputTensor?.close()
|
||||
recOutputs?.close()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (resized !== bitmap) {
|
||||
resized?.recycle()
|
||||
}
|
||||
detInputTensor?.close()
|
||||
detOutputs?.close()
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ============== 前处理 ==============
|
||||
|
||||
/** det 前处理:resize → BCHW → normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225])。 */
|
||||
private fun preprocessDet(bmp: Bitmap): TensorData {
|
||||
val w = bmp.width
|
||||
val h = bmp.height
|
||||
val pixels = IntArray(w * h)
|
||||
bmp.getPixels(pixels, 0, w, 0, 0, w, h)
|
||||
val data = FloatArray(3 * w * h)
|
||||
// BCHW 顺序
|
||||
val means = floatArrayOf(0.485f, 0.456f, 0.406f)
|
||||
val stds = floatArrayOf(0.229f, 0.224f, 0.225f)
|
||||
for (c in 0..2) {
|
||||
val mean = means[c]
|
||||
val std = stds[c]
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val px = pixels[y * w + x]
|
||||
// 提取 R/G/B(c=0→R, 1→G, 2→B)
|
||||
val channelVal = (px shr (16 - 8 * c)) and 0xFF
|
||||
data[c * w * h + y * w + x] = (channelVal / 255.0f - mean) / std
|
||||
}
|
||||
}
|
||||
}
|
||||
return TensorData(data, w, h)
|
||||
}
|
||||
|
||||
/** rec 前处理:crop → resize 到 48 高(保持宽高比)→ pad 到整除 4 → normalize。 */
|
||||
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()
|
||||
// 宽度上限,避免单行过长爆显存
|
||||
resizedW = min(resizedW, REC_MAX_WIDTH)
|
||||
resizedW = max(resizedW, 1)
|
||||
val resized = if (resizedW == w && h == REC_IMAGE_HEIGHT) bmp
|
||||
else Bitmap.createScaledBitmap(bmp, resizedW, REC_IMAGE_HEIGHT, true)
|
||||
w = resized.width
|
||||
val pixels = IntArray(w * REC_IMAGE_HEIGHT)
|
||||
resized.getPixels(pixels, 0, w, 0, 0, w, REC_IMAGE_HEIGHT)
|
||||
val data = FloatArray(3 * w * REC_IMAGE_HEIGHT)
|
||||
val means = floatArrayOf(0.5f, 0.5f, 0.5f)
|
||||
val stds = floatArrayOf(0.5f, 0.5f, 0.5f)
|
||||
for (c in 0..2) {
|
||||
val mean = means[c]
|
||||
val std = stds[c]
|
||||
for (y in 0 until REC_IMAGE_HEIGHT) {
|
||||
for (x in 0 until w) {
|
||||
val px = pixels[y * w + x]
|
||||
val channelVal = (px shr (16 - 8 * c)) and 0xFF
|
||||
data[c * w * REC_IMAGE_HEIGHT + y * w + x] = (channelVal / 255.0f - mean) / std
|
||||
}
|
||||
}
|
||||
}
|
||||
if (resized !== bmp) resized.recycle()
|
||||
return TensorData(data, w, REC_IMAGE_HEIGHT)
|
||||
}
|
||||
|
||||
// ============== DB 后处理(简化版) ==============
|
||||
// 参考 PaddleOCR db_postprocess:sigmoid → threshold → 连通域 → 最小外接矩形
|
||||
// 这里用轻量实现:逐像素阈值化后用投影法估框,对常见单/多行账单足够。
|
||||
|
||||
/**
|
||||
* DB 后处理:sigmoid + 阈值 0.3 → 二值图 → 连通域外接矩形。
|
||||
* 简化版:用水平投影切行 + 垂直投影切列,得到矩形框(对账单类版面够用)。
|
||||
*/
|
||||
private fun dbPostprocess(prob: Array<FloatArray>, h: Int, w: Int, ratioX: Float, ratioY: Float): List<List<FloatArray>> {
|
||||
// 自动检测是否需要 Sigmoid
|
||||
var minVal = Float.MAX_VALUE
|
||||
var maxVal = Float.MIN_VALUE
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
val v = prob[y][x]
|
||||
if (v < minVal) minVal = v
|
||||
if (v > maxVal) maxVal = v
|
||||
}
|
||||
}
|
||||
val needSigmoid = minVal < -0.05f || maxVal > 1.05f
|
||||
Log.i(OCR_MODULE_NAME, "DB prob min=$minVal, max=$maxVal, needSigmoid=$needSigmoid")
|
||||
|
||||
// 过滤状态栏(前8%)和导航栏(后8%),避免其上的干扰字符(如电池、时间、返回键)影响识别或导致行粘连
|
||||
val startY = (h * 0.08).toInt()
|
||||
val endY = (h * 0.92).toInt()
|
||||
|
||||
val binMask = Array(h) { IntArray(w) }
|
||||
var activeCountTotal = 0
|
||||
for (y in 0 until h) {
|
||||
for (x in 0 until w) {
|
||||
if (y < startY || y > endY) {
|
||||
binMask[y][x] = 0
|
||||
continue
|
||||
}
|
||||
val raw = prob[y][x]
|
||||
val sig = if (needSigmoid) {
|
||||
1.0f / (1.0f + Math.exp(-raw.toDouble()).toFloat())
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
val isActive = if (sig > DET_THRESH) 1 else 0
|
||||
binMask[y][x] = isActive
|
||||
if (isActive == 1) activeCountTotal++
|
||||
}
|
||||
}
|
||||
Log.i(OCR_MODULE_NAME, "二值化完成: 活跃像素 = $activeCountTotal / ${w * h}")
|
||||
|
||||
// 清理垂直干扰线(如滚动条、背景边框线):如果某列在文本有效区域内的活跃像素超过该区域高度的 30%,视为干扰列,整列清零
|
||||
val maxColActive = ((endY - startY) * 0.3).toInt()
|
||||
var clearedColsCount = 0
|
||||
for (x in 0 until w) {
|
||||
var colActive = 0
|
||||
for (y in startY..endY) {
|
||||
if (binMask[y][x] == 1) colActive++
|
||||
}
|
||||
if (colActive > maxColActive) {
|
||||
clearedColsCount++
|
||||
for (y in 0 until h) {
|
||||
binMask[y][x] = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.i(OCR_MODULE_NAME, "垂直线噪清理完成: 清理了 $clearedColsCount / $w 列")
|
||||
|
||||
// 水平投影:按行找文本行
|
||||
val rowHits = IntArray(h)
|
||||
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
|
||||
}
|
||||
}
|
||||
if (inLine) rowRanges.add(intArrayOf(lineStart, h - 1))
|
||||
Log.i(OCR_MODULE_NAME, "dbPostprocess 水平分割完成,找到行Ranges数 = ${rowRanges.size}")
|
||||
|
||||
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),
|
||||
))
|
||||
}
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
|
||||
// ============== CTC 解码 ==============
|
||||
|
||||
/**
|
||||
* CTC greedy decode:每个时间步取 argmax,去 blank 去重复。返回 (text, avgConfidence)。
|
||||
*
|
||||
* PaddleOCR 约定:logits 的 index 0 固定是 blank,字符从 index 1 起,
|
||||
* dictionary[i] 对应模型输出 index i+1。因此 dictIdx = argmaxIdx - 1。
|
||||
* 已用 onnxruntime 实证:argmax 序列中 0 占多数(即 blank),真实字符索引
|
||||
* (如 90→'支')按 idx-1 映射到 dictionary 即可正确还原中文。
|
||||
*/
|
||||
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
|
||||
if (logits.isEmpty()) return "" to 0f
|
||||
val numClasses = logits[0].size
|
||||
val blankIdx = 0 // PaddleOCR CTC:blank 固定在 index 0
|
||||
|
||||
val sb = StringBuilder()
|
||||
var lastIdx = -1
|
||||
var confSum = 0.0f
|
||||
var confCount = 0
|
||||
for (t in logits.indices) {
|
||||
var maxIdx = 0
|
||||
var maxVal = logits[t][0]
|
||||
for (i in 1 until numClasses) {
|
||||
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
|
||||
}
|
||||
// softmax 概率(用于置信度统计)
|
||||
var expSum = 0.0
|
||||
for (i in 0 until numClasses) expSum += Math.exp(logits[t][i].toDouble())
|
||||
val prob = Math.exp(maxVal.toDouble()) / expSum
|
||||
|
||||
if (maxIdx != blankIdx && maxIdx != lastIdx) {
|
||||
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
|
||||
if (dictIdx in 0 until dictionary.size) {
|
||||
sb.append(dictionary[dictIdx])
|
||||
confSum += prob.toFloat()
|
||||
confCount++
|
||||
}
|
||||
}
|
||||
lastIdx = maxIdx
|
||||
}
|
||||
val avgConf = if (confCount > 0) confSum / confCount else 0f
|
||||
return sb.toString() to avgConf
|
||||
}
|
||||
|
||||
// ============== Bitmap 工具 ==============
|
||||
|
||||
private fun cropBox(bmp: Bitmap, box: List<FloatArray>): Bitmap? {
|
||||
val xs = box.map { it[0] }
|
||||
val ys = box.map { it[1] }
|
||||
val paddingX = 4
|
||||
val paddingY = 2
|
||||
val minX = max(0, (xs.minOrNull() ?: 0f).toInt() - paddingX)
|
||||
val minY = max(0, (ys.minOrNull() ?: 0f).toInt() - paddingY)
|
||||
val maxX = min(bmp.width, ((xs.maxOrNull() ?: 0f) + 1).toInt() + paddingX)
|
||||
val maxY = min(bmp.height, ((ys.maxOrNull() ?: 0f) + 1).toInt() + paddingY)
|
||||
val w = maxX - minX
|
||||
val h = maxY - minY
|
||||
if (w < 2 || h < 2) return null
|
||||
return Bitmap.createBitmap(bmp, minX, minY, w, h)
|
||||
}
|
||||
|
||||
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
|
||||
return Bitmap.createScaledBitmap(bmp, alignedW, alignedH, true)
|
||||
}
|
||||
|
||||
// ============== 公共工具(复用) ==============
|
||||
|
||||
/** 解码 base64 图片为 Bitmap。 */
|
||||
private fun decodeBase64(base64: String): Bitmap? {
|
||||
return try {
|
||||
// 去除 data:image/...;base64, 前缀
|
||||
val data = if (base64.contains(",")) base64.substringAfter(",") else base64
|
||||
val bytes = Base64.decode(data, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (e: Exception) {
|
||||
Log.e(OCR_MODULE_NAME, "base64 解码失败: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 短边压缩到 maxShortEdge(参考 AutoAccounting scaleDownForOcr)。
|
||||
* 像素量比 1440p 减少约 75%,识别速度大幅提升。
|
||||
*/
|
||||
private fun scaleDownForOcr(bitmap: Bitmap, maxShortEdge: 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()
|
||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||
}
|
||||
|
||||
override fun onCatalystInstanceDestroy() {
|
||||
super.onCatalystInstanceDestroy()
|
||||
release()
|
||||
}
|
||||
|
||||
override fun invalidate() {
|
||||
super.invalidate()
|
||||
release()
|
||||
}
|
||||
|
||||
private fun release() {
|
||||
lock.lock()
|
||||
try {
|
||||
detSession?.close()
|
||||
recSession?.close()
|
||||
// OrtEnvironment 是单例,不主动 close(进程级)
|
||||
detSession = null
|
||||
recSession = null
|
||||
ortEnv = null
|
||||
initialized = false
|
||||
} catch (_: Exception) {
|
||||
} finally {
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
/** 识别结果块。 */
|
||||
private data class OcrBlock(val text: String, val x: Float, val y: Float, val width: Float, val height: Float, val confidence: Float)
|
||||
/** 预处理后的张量数据 + 宽高。 */
|
||||
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
|
||||
/** rec 固定图像高度。 */
|
||||
private const val REC_IMAGE_HEIGHT = 48
|
||||
/** rec 单行最大宽度。 */
|
||||
private const val REC_MAX_WIDTH = 320
|
||||
/** assets 中的模型/字典文件名。 */
|
||||
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx"
|
||||
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx"
|
||||
// PP-OCRv5 多语言识别模型的配套字典(18383 字符 + 运行时 1 blank = 18385 维输出)。
|
||||
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt(6623)会导致 CTC 解码乱码。
|
||||
private const val ASSET_DICT = "ppocrv5_dict.txt"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.beancount.mobile.ppocr
|
||||
|
||||
import android.view.View
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ReactShadowNode
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* 注册 OcrModule 到 React Native 的 Package(plan.md「3.2 Config Plugin」)。
|
||||
*
|
||||
* 由 app.plugin.js 的 withMainApplication 注入到 MainApplication.getPackages() 列表。
|
||||
* RN 在启动时遍历所有 Package,调用 createNativeModules 注册原生模块。
|
||||
*/
|
||||
class OcrPackage : ReactPackage {
|
||||
override fun createNativeModules(rc: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(OcrModule(rc))
|
||||
}
|
||||
|
||||
override fun createViewManagers(rc: ReactApplicationContext): List<ViewManager<View, ReactShadowNode<*>>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -103,11 +103,10 @@ function withPpOcr(config) {
|
||||
config = withAppBuildGradle(config, (modConfig) => {
|
||||
let gradle = modConfig.modResults.contents;
|
||||
if (!gradle.includes('onnxruntime')) {
|
||||
// 在 dependencies { ... } 块末尾追加
|
||||
// 在 dependencies { 开头处插入,避免嵌套花括号的正则匹配错误
|
||||
gradle = gradle.replace(
|
||||
/dependencies\s*{([\s\S]*?)^\s*}/m,
|
||||
(m, inner) =>
|
||||
`dependencies {${inner}\n // PP-OCRv5 ONNX Runtime(由 Config Plugin 注入)\n implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.20.0'\n}`,
|
||||
/(dependencies\s*\{)/,
|
||||
`$1\n // PP-OCRv5 ONNX Runtime(由 Config Plugin 注入)\n implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.20.0'`
|
||||
);
|
||||
}
|
||||
modConfig.modResults.contents = gradle;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
package com.beancount.mobile.screenshot
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import android.provider.MediaStore
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.beancount.mobile.accessibility.ReactContextHolder
|
||||
|
||||
/**
|
||||
* 截图监控 RN Module(plan.md「3.13 截图自动记账通道」)。
|
||||
*
|
||||
* 由 JS 端调用 start()/stop() 控制 ContentObserver 的注册/注销。
|
||||
* 检测到截图时读取 base64 并通过 DeviceEventEmitter 发送到 JS。
|
||||
*
|
||||
* 事件格式(WritableNativeMap)与 BillingAccessibilityService.sendScreenshotEvent 一致:
|
||||
* { base64: "data:image/jpeg;base64,...", packageName: "...", timestamp: Long, displayName: "..." }
|
||||
*/
|
||||
class ScreenshotModule(private val reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
private var observer: ScreenshotObserver? = null
|
||||
|
||||
override fun getName() = "ScreenshotMonitor"
|
||||
|
||||
override fun invalidate() {
|
||||
stop()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动截图监控。
|
||||
* 在主线程注册 ContentObserver,检测到截图时发送 billingScreenshot 事件。
|
||||
*/
|
||||
@ReactMethod
|
||||
fun start() {
|
||||
if (observer != null) return
|
||||
// 确保 ReactContextHolder 有上下文
|
||||
ReactContextHolder.context = reactContext
|
||||
observer = ScreenshotObserver(reactContext) { uri ->
|
||||
sendScreenshotEvent(uri)
|
||||
}
|
||||
observer?.register()
|
||||
}
|
||||
|
||||
/** 停止截图监控。 */
|
||||
@ReactMethod
|
||||
fun stop() {
|
||||
observer?.unregister()
|
||||
observer = null
|
||||
}
|
||||
|
||||
/** 读取截图 base64 并发送到 JS(WritableNativeMap 格式,与无障碍服务一致)。 */
|
||||
private fun sendScreenshotEvent(uri: Uri) {
|
||||
try {
|
||||
val resolver = reactContext.contentResolver
|
||||
// 读取图片为 base64
|
||||
val inputStream = resolver.openInputStream(uri) ?: return
|
||||
val bytes = inputStream.use { it.readBytes() }
|
||||
inputStream.close()
|
||||
val base64 = "data:image/png;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
|
||||
// 同时查询显示名
|
||||
val projection = arrayOf(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
var displayName = ""
|
||||
resolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
displayName = cursor.getString(0) ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
val map = WritableNativeMap()
|
||||
map.putString("base64", base64)
|
||||
map.putString("uri", uri.toString())
|
||||
map.putString("packageName", "")
|
||||
map.putString("displayName", displayName)
|
||||
map.putDouble("timestamp", System.currentTimeMillis().toDouble())
|
||||
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingScreenshot", map)
|
||||
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ScreenshotModule", "发送截图事件失败: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.beancount.mobile.screenshot
|
||||
|
||||
import android.content.Context
|
||||
import android.database.ContentObserver
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* 截图监听 ContentObserver(plan.md「3.13 截图自动记账通道」)。
|
||||
*
|
||||
* 参考 BeeCount 的 ScreenshotObserver.kt:
|
||||
* - 监听 MediaStore.Images.Media.EXTERNAL_CONTENT_URI 变化
|
||||
* - 关键词匹配(screenshot/截屏/截图/screen_shot)
|
||||
* - 30 秒时间窗(过滤旧截图)
|
||||
* - processedPaths 去重(最多 200 条)
|
||||
* - 过滤小米 .pending- 临时文件
|
||||
* - 500ms 防抖
|
||||
*
|
||||
* 由 Config Plugin 注册,触发后通过 DeviceEventEmitter 推送到 JS 层 ScreenshotChannel。
|
||||
*
|
||||
* ⚠️ 与 src/domain/constants.ts 同步:TIME_WINDOW_MS、MAX_PROCESSED、SCREENSHOT_KEYWORDS
|
||||
* 修改时需两边同时更新。
|
||||
*/
|
||||
class ScreenshotObserver(
|
||||
private val context: Context,
|
||||
private val handler: Handler = Handler(Looper.getMainLooper()),
|
||||
private val onScreenshot: (uri: Uri) -> Unit,
|
||||
) : ContentObserver(handler) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ScreenshotObserver"
|
||||
private const val TIME_WINDOW_MS = 30_000L
|
||||
private const val MAX_PROCESSED = 200
|
||||
private val SCREENSHOT_KEYWORDS = listOf("screenshot", "截屏", "截图", "screen_shot", "Screenshot")
|
||||
}
|
||||
|
||||
private val resolver = context.contentResolver
|
||||
private val processedPaths = LinkedHashSet<String>()
|
||||
@Volatile private var lastCheckTime = System.currentTimeMillis()
|
||||
|
||||
/** 注册监听。 */
|
||||
fun register() {
|
||||
resolver.registerContentObserver(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||
true,
|
||||
this,
|
||||
)
|
||||
Log.i(TAG, "截图监听已注册")
|
||||
}
|
||||
|
||||
/** 注销监听。 */
|
||||
fun unregister() {
|
||||
resolver.unregisterContentObserver(this)
|
||||
Log.i(TAG, "截图监听已注销")
|
||||
}
|
||||
|
||||
override fun onChange(selfChange: Boolean, uri: Uri?) {
|
||||
super.onChange(selfChange, uri)
|
||||
uri ?: return
|
||||
handler.post { processNewScreenshot(uri) }
|
||||
}
|
||||
|
||||
private fun processNewScreenshot(uri: Uri) {
|
||||
try {
|
||||
// 查询截图信息
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DATA,
|
||||
MediaStore.Images.Media.DATE_ADDED,
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
)
|
||||
resolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||
if (!cursor.moveToFirst()) return
|
||||
val path = cursor.getString(0) ?: ""
|
||||
val dateAdded = cursor.getLong(1)
|
||||
val displayName = cursor.getString(2) ?: ""
|
||||
|
||||
// 1. 时间窗过滤(30 秒内)
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
if (now - dateAdded > TIME_WINDOW_MS / 1000) {
|
||||
Log.d(TAG, "忽略旧截图(超过30秒): $displayName")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 关键词匹配(必须是截图)
|
||||
if (!SCREENSHOT_KEYWORDS.any { kw -> displayName.contains(kw, true) || path.contains(kw, true) }) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 过滤小米 .pending- 临时文件
|
||||
if (path.endsWith(".pending-") || path.contains(".pending-")) {
|
||||
Log.d(TAG, "过滤小米 pending 临时文件: $displayName")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 去重
|
||||
if (processedPaths.contains(path)) return
|
||||
processedPaths.add(path)
|
||||
if (processedPaths.size > MAX_PROCESSED) {
|
||||
val it = processedPaths.iterator()
|
||||
if (it.hasNext()) {
|
||||
it.next()
|
||||
it.remove()
|
||||
}
|
||||
}
|
||||
|
||||
Log.i(TAG, "检测到新截图: $displayName")
|
||||
onScreenshot(uri)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "处理截图异常: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.beancount.mobile.screenshot
|
||||
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.uimanager.ViewManager
|
||||
|
||||
/**
|
||||
* ReactPackage 注册 ScreenshotModule。
|
||||
* 由 Config Plugin 的 withMainApplication 注入 add(ScreenshotPackage()) 到 MainApplication。
|
||||
*/
|
||||
class ScreenshotPackage : ReactPackage {
|
||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
||||
return listOf(ScreenshotModule(reactContext))
|
||||
}
|
||||
|
||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const { withDangerousMod } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function withSizeOptimization(config) {
|
||||
// 1. 在 prebuild 时修改 gradle.properties
|
||||
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');
|
||||
if (content.includes('reactNativeArchitectures=')) {
|
||||
content = content.replace(/reactNativeArchitectures=.*/, 'reactNativeArchitectures=arm64-v8a');
|
||||
} else {
|
||||
content += '\nreactNativeArchitectures=arm64-v8a\n';
|
||||
}
|
||||
fs.writeFileSync(propertiesPath, content, 'utf8');
|
||||
}
|
||||
return modConfig;
|
||||
}
|
||||
]);
|
||||
|
||||
// 2. 在 prebuild 时修改 app/build.gradle 启用 ABI Splits 分包
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (modConfig) => {
|
||||
const projectRoot = modConfig.modRequest.platformProjectRoot;
|
||||
const buildGradlePath = path.join(projectRoot, 'app/build.gradle');
|
||||
if (fs.existsSync(buildGradlePath)) {
|
||||
let content = fs.readFileSync(buildGradlePath, 'utf8');
|
||||
if (!content.includes('splits {')) {
|
||||
content = content.replace(
|
||||
/android\s*\{/,
|
||||
`android {\n splits {\n abi {\n enable true\n reset()\n include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"\n universalApk true\n }\n }`
|
||||
);
|
||||
fs.writeFileSync(buildGradlePath, content, 'utf8');
|
||||
}
|
||||
}
|
||||
return modConfig;
|
||||
}
|
||||
]);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = withSizeOptimization;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "size-optimization",
|
||||
"main": "app.plugin.js"
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.beancount.mobile.sms
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Telephony
|
||||
import android.telephony.SmsMessage
|
||||
import android.util.Log
|
||||
import com.facebook.react.bridge.WritableNativeMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule
|
||||
import com.beancount.mobile.accessibility.ReactContextHolder
|
||||
|
||||
/**
|
||||
* 短信监听 Receiver(plan.md「4.2 短信监听服务」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 参考 AutoAccounting 的 SmsReceiver:
|
||||
* - 监听 SMS_RECEIVED_ACTION
|
||||
* - 从 PDU 解析发送方 + 正文
|
||||
* - 关键词预过滤(银行短信含「交易/消费/余额」等,JS 层进一步过滤)
|
||||
* - 通过 DeviceEventEmitter 推送到 JS 层 SmsChannel
|
||||
*
|
||||
* 需在 manifest 注册 RECEIVE_SMS 权限(由 Config Plugin 注入)。
|
||||
*/
|
||||
class BillingSmsReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingSms"
|
||||
|
||||
/** 银行短信关键词(预过滤,减少 JS 层负担)。 */
|
||||
private val BANK_KEYWORDS = listOf("交易", "消费", "收入", "支出", "余额", "转账", "入账", "扣款", "退款")
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return
|
||||
|
||||
runCatching {
|
||||
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)
|
||||
for (message in messages) {
|
||||
val sender = message.displayOriginatingAddress ?: ""
|
||||
val body = message.displayMessageBody ?: ""
|
||||
if (sender.isBlank() || body.isBlank()) continue
|
||||
|
||||
// 关键词预过滤(仅处理疑似银行短信)
|
||||
if (!BANK_KEYWORDS.any { body.contains(it) }) continue
|
||||
|
||||
Log.d(TAG, "收到银行短信: sender=$sender")
|
||||
sendSmsEvent(sender, body)
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, "短信处理异常: ${it.message}", it)
|
||||
}
|
||||
}
|
||||
|
||||
/** 把短信事件推送到 JS 层 SmsChannel.handleSms。 */
|
||||
private fun sendSmsEvent(sender: String, body: String) {
|
||||
val reactContext = ReactContextHolder.context ?: run {
|
||||
Log.w(TAG, "RN 上下文未就绪,丢弃短信")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val map = WritableNativeMap().apply {
|
||||
putString("sender", sender)
|
||||
putString("body", body)
|
||||
putDouble("timestamp", System.currentTimeMillis().toDouble())
|
||||
}
|
||||
reactContext
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||
.emit("billingSms", map)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "推送短信事件失败: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user