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:
fengmengqi
2026-07-18 18:02:45 +08:00
parent f6437b83fe
commit 76a5853ab6
133 changed files with 26279 additions and 8594 deletions
@@ -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 → base64JPEG 质量 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" />
+4 -32
View File
@@ -62,44 +62,16 @@ function withAccessibilityService(config) {
},
]);
// 2. 注入 ReactContextHolder 赋值 + 注册 AccessibilityBridgePackage 到 MainApplication
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
// 2a. 注入 importReactContextHolder + AccessibilityBridgePackage
if (!content.includes(`import ${PACKAGE}.ReactContextHolder`)) {
// 2a. 注入 importAccessibilityBridgePackage
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
View File
@@ -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 KBPaddleOCR 标准字典
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`),导致新代码找不到配套字典。
+659
View File
@@ -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 Bridgeplan.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.txtPP-OCRv5 多语言字典,18383 字符;rec 模型 18385 维输出 = 字典 + blank + 特殊位)。
*
* 流水线:
* 1. det(文本检测):bitmap → DB 后处理得到文本框
* 2. rec(文本识别):每个框 crop → resize 到 48px 高 → CTC 解码
*
* 性能优化(参考 AutoAccounting OcrProcessor.kt):
* - 短边压缩到 720px(像素量比 1440p 减少约 75%
* - CPU 执行(兼容性最稳,部分设备 GPU 会崩溃)
* - det 最大边限制 960PaddleOCR 默认 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/Bc=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_postprocesssigmoid → 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 CTCblank 固定在 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.txt6623)会导致 CTC 解码乱码。
private const val ASSET_DICT = "ppocrv5_dict.txt"
}
}
+24
View File
@@ -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 的 Packageplan.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()
}
}
+3 -4
View File
@@ -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 Moduleplan.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 并发送到 JSWritableNativeMap 格式,与无障碍服务一致)。 */
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
/**
* 截图监听 ContentObserverplan.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()
}
}
+48
View File
@@ -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;
+4
View File
@@ -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
/**
* 短信监听 Receiverplan.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}")
}
}
}