DriftLedger/plugins/accessibility/android/BillingAccessibilityService.kt
fengmengqi 7fa345b558 feat: 品牌重命名为浮记(DriftLedger),全面升级精度安全与架构
品牌与配置
- 重命名 Bean Mobile → 浮记(DriftLedger),slug 改为 drift-ledger
- 所有 Config Plugin 从 app.json 动态读取 appId,替换硬编码 com.beancount.mobile
- 预构建时自动从 constants.ts 同步支付包名列表到 Kotlin 端
- size-optimization 插件新增 NDK 版本强制统一(27.1.12297006)
- 添加 app icon(direction_a_feather.png)
Decimal 精度改造
- 全面消除 parseFloat 用于金额计算的场景,改用 string decimal 运算
- 新增 divideDecimals(除法)、toDateString(Date→YYYY-MM-DD)
- negateDecimal 零值特殊处理,避免产生 "-0"
- 年报/月报/预算/信用卡/汇率/图表统计等模块全部迁移
- 去重指纹用 parseDecimal 归一化金额格式
- 转账识别增加 normalizeAmount,支持不同精度格式配对(100 vs 100.00)
架构变更
- mobile.bean → main.bean:移除启动时合并迁移逻辑,直接读写 main.bean
- TransactionDraft.sourceEventId → sourceEventIds(数组,支持转账双方)
- 新增 buildPostings() 纯函数,从 transactionBuilder 提取复式分录构建逻辑
- 新增 csvUtils.ts(引号感知 CSV 行拆分),statements/adapters 共用
- 新增 accessibilityParser.ts:从 automationPipeline 提取微信/支付宝文本解析
- 新增 floatingBillManager.ts:悬浮窗草稿管理(LRU+超时)与排序数据构建
- 新增 txKeyStore.ts:交易级去重键持久化(原子写入+500条LRU)
- ledger.ts 新增 resolveIncludes 选项,支持 include 指令递归解析
- metadataStore CRUD 用 createCrudActions 工厂消除7组实体重复代码
安全与持久化
- PIN 哈希改用带盐 SHA-256,兼容旧版无 salt 数据迁移
- storePersistence 从 persistTo 回调改为 subscribe 自动触发
- 持久化增加深比较跳过无变化写入 + 滞后重试
- 新增 flushPersistence(),App 切后台时立即刷盘
- CrashReporter 新增文件持久化(CrashFs 抽象)
- settingsStore hydrate 期间跳过自动持久化(skipPersist 标志)
去重与管道
- batchDedup 改用日期索引 Map,候选查找从 O(n) 降至 O(1)
- dedupAgainstHistory 按日期分组,仅扫描时间窗口内候选
- OcrProcessor 从布尔标志改为 Promise 队列,不再丢弃并发请求
- 去重缓存改用 Map<hash,timestamp> LRU,阈值超20%时批量淘汰
- 悬浮球全局开关(floatingBallEnabled) + 原生 bridge API
其他改进
- recurring 计算修复月/年溢出(1月31日+1月→2月28日,闰年2月29日)
- 预算进度按 posting 分录金额求和(不再仅过滤负数金额)
- recurring.ts 用 Date.parse 本地时间替代 toISOString 的 UTC 偏移
- 信用卡账单日/还款日限制不超过28,防止月份溢出
- 类别模糊匹配增加最小长度2,避免单字符误匹配
- 还款检测排除 refund 方向,避免退款被误判为还款
- WebDAV 同步增加 HTTP 日期格式解析
- Git 合并策略改为以 remote 为基础追加 local 独有行
性能优化
- commonStyles 全局 useMemo 缓存
- Button/Card 的 pressIn/pressOut 改用 useCallback
- TransactionCard/AccountTreeNode 包裹 React.memo
- PostingEditor 用 refs + 函数式更新避免重建
- CreditCard 页预计算账户余额 Map,避免渲染循环重复遍历
- OCR 处理器单例复用,配置变更时才重建
- getLocalAuth/getAccessibilityBridge 结果缓存
测试
- 新增 decimal/constants/channelConfig/transactionBuilder 单测
- 修复金额格式断言(-24.5→-24.50),匹配字符串精度行为
- billPipeline 新增不同格式金额配对、指纹唯一性测试
2026-07-21 15:16:44 +08:00

622 lines
24 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.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
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
@Volatile
var floatingBallEnabled = true
/**
* 支付 App 包名自动生成来源src/domain/constants.ts PAYMENT_PACKAGES
* 修改时只需编辑 constants.ts运行 expo prebuild 即可同步。
*/
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 var ocrDoingTimestamp = 0L
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()
// 修复:检查当前前台 App恢复悬浮球
val root = rootInActiveWindow
val pkg = root?.packageName?.toString()
root?.recycle()
if (pkg != null) {
topPackage = pkg
updateFloatingHelperVisibility(pkg)
}
}
/** 动态配置服务能力(截图 + 页面变化监听)。 */
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?) {
// ocrDoing 超时兜底5秒后强制重置
if (ocrDoing) {
if (System.currentTimeMillis() - ocrDoingTimestamp > 5000) {
Log.w(TAG, "ocrDoing 超时重置")
ocrDoing = false
} else {
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)
// 页面切换时检查是否有已记住的签名需要触发文本提取
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 -> {
// 内容变化时也检查(防抖合并)
scheduleContentChange()
}
}
}
private fun updateFloatingHelperVisibility(pkg: String?) {
handler.post {
val shouldShow = floatingBallEnabled
&& pkg != null
&& !filterPackage(pkg, "")
&& !isLandscape()
if (shouldShow) {
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)
}
/** 内容变化处理:检查页面签名 → 提取文本 → 发送给 JSJS 控制是否 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
ocrDoingTimestamp = System.currentTimeMillis()
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}")
}
}
/** 关闭悬浮球(由 JS 层 setFloatingBallEnabled(false) 调用)。 */
fun dismissFloatingHelper() {
handler.post {
floatingHelper?.dismiss()
floatingHelper = null
}
}
/** 刷新悬浮球状态(由 JS 层 setFloatingBallEnabled(true) 调用)。 */
fun refreshFloatingHelper() {
handler.post {
updateFloatingHelperVisibility(topPackage)
}
}
/** 横屏检测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
}
/** 过滤不应处理的系统组件桌面启动器、SystemUI、输入法等。 */
private fun filterPackage(pkg: String, className: String): Boolean {
val p = pkg.lowercase()
// 自身 App只放行 MainActivity避免悬浮球容器触发自毁式关闭
if (pkg == packageName) {
return !className.endsWith(".MainActivity")
}
// 桌面启动器(悬浮球在桌面无意义)
if (LAUNCHER_PACKAGES.any { p.contains(it) }) return true
// SystemUI / 输入法(悬浮球在这些组件上会遮挡系统 UI
if (p.contains("systemui") || p.contains("inputmethod") || p.contains("keyboard")) 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
}