DriftLedger/plugins/accessibility/android/AccessibilityBridgeModule.kt
fengmengqi bf04400852 feat: OCR 模型按需下载 + 三层独立开关 + 日志持久化 + 原生浮层主题同步 + 文档重构
OCR 模型按需下载(P8 瘦身):
- 移除 plugins/ppocr/assets/ 内置模型(det 9.8MB + rec 21MB + dict),APK 减包 ~31MB
- 新增 modelDownloader.ts:优先从 HuggingFace/CDN 下载,兜底从 APK assets 拷贝
- OcrModule.kt 新增 setModelDir,支持从 filesystem 加载模型,回退 assets 兼容旧用户
- settingsStore 持久化 ocrModelVersion / ocrModelDir
- 自动化页集成模型状态检查与一键下载 UI

OCR 三层独立控制:
- OcrProcessorConfig 从单一 aiVisionEnabled 拆分为 layer1/2/3 三个独立开关
- 设置页可按层启停(L1 正则规则 / L2 本地 OCR / L3 AI Vision)
- OCR 处理增加耗时与字符数日志

日志系统升级:
- logger.ts 新增 LogFileBackend 抽象,支持磁盘持久化(按日期 app-YYYY-MM-DD.log)
- 新增 logBackend.ts(ExpoLogFileBackend)+ 日志中心页 settings/logs.tsx
- 日志中心:实时缓冲 + 历史文件、4 级过滤、Tag/关键词搜索、JSON 展开、分享导出、7 天过期清理
- _layout.tsx 启动时初始化文件后端 + 日志脱敏(验证码/卡号)

原生浮层 UI 主题同步(P6):
- 新增 floatingUiConfig.ts:JS 侧从 theme tokens + i18n 构建 FloatingUiConfig 推送原生
- 新增 FloatingUiConfigStore.kt:SharedPreferences 存储,三浮层组件读取
- FloatingBillView 重设计:颜色/文案走配置、新增币种 chip、金额校验改 BigDecimal
- FloatingHelper / FloatingTip 同步适配
- _layout.tsx 新增 FloatingUiConfigSyncer,主题/语言切换自动推送

UI 与组件增强:
- FormModal 新增 select/dropdown 控件、行内布局(row/flex)、联动回调 onValuesChange
- 新增 Touchable 通用触摸组件、AccountCreateModal 快速建账弹窗
- 信用卡页展示账单周期/到期还款日/本期应还/剩余可用额度,关联账户改下拉选择
- AI 设置页重做:OpenAI/Gemini/DeepSeek 预设 + 默认 URL/模型
- 引导页新增 Android 权限检查步骤(无障碍/通知/短信/存储/悬浮窗)

去重优化:
- 对手方匹配改为模糊包含(includes),双方均无对手方时判定低置信度重复
- DedupResult 新增 matchedItem 返回匹配对比项

文档重构:
- README.md 重写为入口索引(品牌更新 + 模块概览 + 文档导航表)
- 新增 AGENTS.md(AI 助手贡献指南)、docs/architecture.md(Mermaid 数据流/分层/OCR 级联图)
- 新增 docs/development.md(环境/命令/编码规范/测试/提交规范)、plugins/README.md
- UI 重设计文档(design spec + p1-p8)移入 docs/design/

其他:
- i18n 新增权限/信用卡详情/日志中心/AI 设置等翻译键
- ppocr Config Plugin 修复 import 注入去重;size-optimization 增强
- 新增测试:logger.test.ts、floating-ui-config.test.ts
2026-07-23 19:03:38 +08:00

325 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package com.beancount.mobile.accessibility
import android.util.Log
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
import com.facebook.react.bridge.ReadableMap
/**
* 无障碍服务 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) {
companion object {
private const val TAG = "AccessibilityBridge"
}
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,
currency: String,
draftId: String,
promise: Promise
) {
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
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, currency)
floatingView.show()
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FAIL", e.message)
}
}
}
/** 设置悬浮球开关状态。 */
@ReactMethod
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
BillingAccessibilityService.floatingBallEnabled = enabled
val service = BillingAccessibilityService.instance
if (service == null) {
promise.resolve(true)
return
}
if (!enabled) {
service.dismissFloatingHelper()
} else {
service.refreshFloatingHelper()
}
promise.resolve(true)
}
/** 从 APK assets 复制 OCR 模型文件到 filesDir/ocr_models_v6。 */
@ReactMethod
fun copyOcrModelsFromAssets(promise: Promise) {
try {
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
if (!destDir.exists()) destDir.mkdirs()
val assetManager = reactContext.assets
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
for (filename in files) {
val destFile = java.io.File(destDir, filename)
if (destFile.exists() && destFile.length() > 0) continue
assetManager.open(filename).use { input ->
java.io.FileOutputStream(destFile).use { output ->
input.copyTo(output)
}
}
}
promise.resolve(destDir.absolutePath)
} catch (e: Exception) {
promise.reject("COPY_MODEL_FAIL", e.message)
}
}
/** 检查通知监听服务是否已启用(反射调用 getEnabledListenerPackages避免 API level lint 错误)。 */
@ReactMethod
fun isNotificationListenerEnabled(promise: Promise) {
try {
// 通过 Settings.Secure 读取系统设置(公开 API无需反射不受 hidden API 限制)
// enabled_notification_listeners 格式:
// "com.pkg1/com.pkg1.Service:com.pkg2/com.pkg2.Service"
val flat = android.provider.Settings.Secure.getString(
reactContext.contentResolver,
"enabled_notification_listeners"
) ?: ""
val myPkg = reactContext.packageName
promise.resolve(flat.split(":").any { it.startsWith(myPkg) })
} catch (e: Exception) {
Log.w(TAG, "isNotificationListenerEnabled 检测失败", e)
promise.resolve(false)
}
}
/** 检查悬浮窗权限SYSTEM_ALERT_WINDOW是否已授予。 */
@ReactMethod
fun canDrawOverlays(promise: Promise) {
try {
promise.resolve(android.provider.Settings.canDrawOverlays(reactContext))
} catch (e: Exception) {
promise.resolve(false)
}
}
/**
* 下发浮层 UI 配置(颜色 + 文案)。
* JS 侧从 theme tokens + i18n 构建 config
* 原生存 SharedPreferences三个浮层组件读取。
*/
@ReactMethod
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
try {
FloatingUiConfigStore.save(reactContext, config)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("CONFIG_SAVE_FAIL", e.message)
}
}
}