### 架构重构:三层分层目录化 - domain 拆分为 8 个子目录(core/pipeline/rules/finance/stats/taxonomy/transaction/platform) - components 拆分为 6 个子目录(form/ui/layout/account/category/stats/transaction) - services 拆分为 5 个子目录(automation/data/ocr/security),accessibilityParser 从 automationPipeline 提取 - 新增 ruleConfig.ts — 规则配置唯一数据源(关键词/方向/OCR 模式),与业务逻辑解耦 ### 微信账单抓取:节点混淆绕过(核心突破) - BillingAccessibilityService 重命名为 SelectToSpeakService,完整伪装为系统服务 - 同时伪装包名+类名为 com.google.android.accessibility.selecttospeak,规避微信 8.0.52+ 白名单校验 - Config Plugin 重写:8 个 kt 文件整体复制+package 正则替换+Manifest/import 联动 - 支付宝/微信无障碍文本解析器全面增强(方向推断/账单分类提取/付款方式提取/容错) - 补充文档 accessibility-wechat-guide.md(伪装原理、踩坑全记录) ### 新 UI 组件体系 - Toast:全局轻量 toast(Context Provider + 入场动画 + 操作按钮 + 自动消失) - ErrorBoundary:React class 错误边界(降级 UI + 重试) - EmptyState / ConfirmDialog / SegmentedControl / Skeleton / TimePicker - BottomSheet 重写:SafeAreaProvider 修复、手势下滑关闭、键盘响应式避让 - FormModal 重构:拆出 FormFields 子组件(TextField/SelectField/DropdownField) - 新增 PeriodSwitcher、RangeStatsCard 独立组件 ### 新 Hooks & 工具 - useBottomInset — 统一底部安全区留白 - useKeyboardAvoiding — 键盘高度响应式 hook(替代 translateY 方案) - sanitize.ts — 日志脱敏工具提取 ### 账本增删改增强 - 写锁增加代际计数器(lockGeneration),reset 后旧链 pending 任务自动跳过 set - 新增 restoreTransaction — 撤销删除(重新追加 raw 文本到 mobile.bean) - 删除交易时清除去重缓存(buildTxKeyFromRaw 重建去重键),支持「删了重记」 - editTransaction/deleteTransaction 改用 dr-id 精确定位交易块(避免同名交易定位错误) - appendTransactionsBatch 改从存储直接读取,避免 zustand state 不一致 ### OCR 原生模块增强 - 异步 initEngine 增加 CountDownLatch 等待(最多 15s),解决竞态导致的「引擎未就绪」 - setModelDir 增加去重判断 + file:// 前缀剥离,避免冗余 reload - 推理链路增加分阶段耗时日志(det 推理/det 后处理/rec 识别) - 图片缩放策略重命名(scaleDownForOcr → capLongEdge) ### OCR 模型按需下载 - 移除了启动时自动下载 ~30MB OCR 模型的逻辑 - 改为首次使用 OCR 时才触发下载 ### 设置页重设计 - ScrollView → SectionList 分组卡片布局(iOS 风格分组圆角行+右侧箭头) - 移除 Card 组件包装,直接使用独立分组头+底部关于卡片 ### 首页优化 - ScrollView → FlatList(ListHeaderComponent 承载净资产卡片+待办条) - 日期/金额格式化增加 locale 感知(zh/en) ### 通知管道增强 - NotificationChannel MD5 去重改为批量淘汰(80% 阈值),替代逐个删除 - 增加 debug 日志输出(过滤原因/包名) ### ESLint - 新增 eslint.config.mjs(typescript-eslint + react-hooks + react-native 规则集) - package.json 新增 lint/lint:fix 脚本,引入 5 个 devDependencies ### 文档 - accessibility-wechat-guide.md — 微信无障碍伪装完整方案 - modal-keyboard-guide.md — 弹窗键盘避让方案 - ocr-pipeline-guide.md — OCR 三层层级管线 - OCR及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
709 lines
28 KiB
Kotlin
709 lines
28 KiB
Kotlin
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 守卫(防止重复触发)
|
||
*
|
||
* 伪装说明(绕过微信 8.0.52+ 节点混淆):
|
||
* 微信按 ComponentName(包名/类名)识别系统服务白名单——TalkBack、SelectToSpeak
|
||
* 等系统服务不受节点混淆影响,第三方服务则被混淆。
|
||
* 本服务的 package + class 全部伪装为
|
||
* com.google.android.accessibility.selecttospeak.SelectToSpeakService,
|
||
* 使微信将其识别为系统 SelectToSpeak 服务,从而拿到未混淆的真实节点文本。
|
||
*
|
||
* 伪装由 Config Plugin(plugins/accessibility/app.plugin.js)在 prebuild 时完成:
|
||
* - kt 源码 package 被 rewrite 为 com.google.android.accessibility.selecttospeak
|
||
* - Manifest android:name 写成完整全限定名
|
||
*
|
||
* 注意:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
||
*
|
||
* 通过 DeviceEventEmitter 把识别事件推送到 JS 层 automationStore。
|
||
*
|
||
* ⚠️ 与 src/domain/constants.ts 同步:PAYMENT_PACKAGES
|
||
* 修改时需两边同时更新。
|
||
*/
|
||
class SelectToSpeakService : 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: SelectToSpeakService? = 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.FLAG_INCLUDE_NOT_IMPORTANT_VIEWS 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()
|
||
|
||
// 注意:自动监听调试日志已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)。
|
||
// 账单识别仅在用户点击悬浮球时触发(isManual=true),此时提取完整节点文本。
|
||
}
|
||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
||
// 内容变化时也检查(防抖合并)
|
||
scheduleContentChange()
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun updateFloatingHelperVisibility(pkg: String?) {
|
||
handler.post {
|
||
val shouldShow = floatingBallEnabled
|
||
&& pkg != null
|
||
&& !filterPackage(pkg, topActivity ?: "")
|
||
&& !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)
|
||
}
|
||
|
||
/** 内容变化处理:检查页面签名 → 提取文本 → 发送给 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 // 未记住的页面不自动触发
|
||
}
|
||
|
||
// 抓取并提取屏幕所有无障碍文本(含 WebView 子窗口),并推送至 JS 侧
|
||
val texts = dumpAllTexts()
|
||
|
||
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, packageName)
|
||
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 优先尝试直接文本解析。
|
||
*
|
||
* 使用 dumpAllTexts() 覆盖所有窗口(含 WebView 子窗口),而非仅 rootInActiveWindow。
|
||
* 伪装生效后这条路径会成为微信账单页的主识别路径,必须保证完整性。
|
||
*/
|
||
fun triggerManualExtraction() {
|
||
handler.post {
|
||
floatingHelper?.collapse()
|
||
}
|
||
val pkg = topPackage ?: return
|
||
val activity = topActivity ?: ""
|
||
val sigKey = "$pkg|$activity"
|
||
|
||
val texts = dumpAllTexts()
|
||
|
||
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"
|
||
val uiLabels = FloatingUiConfigStore.load(this).labels
|
||
if (pageSignatures.add(sig)) {
|
||
savePageSignatures()
|
||
Log.i(TAG, "已记住页面: $sig")
|
||
handler.post {
|
||
android.widget.Toast.makeText(this, "${uiLabels.pageRemembered}:\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, "${uiLabels.pageSignatureExists}:\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) {
|
||
if (className.isEmpty()) return false
|
||
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 → base64(JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
|
||
private fun bitmapToBase64(bitmap: Bitmap, packageName: String = ""): String {
|
||
// 安全起见,如果 bitmap 是 HARDWARE 格式,将其复制为 ARGB_8888 软件格式
|
||
val softwareBitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && bitmap.config == Bitmap.Config.HARDWARE) {
|
||
bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||
} else {
|
||
bitmap
|
||
}
|
||
// 诊断:分析截图有效性(区分「截到空白帧」与「有内容但 OCR 失败」)
|
||
analyzeScreenshot(softwareBitmap, packageName)
|
||
val baos = ByteArrayOutputStream()
|
||
softwareBitmap.compress(Bitmap.CompressFormat.JPEG, 60, baos)
|
||
if (softwareBitmap !== bitmap) {
|
||
softwareBitmap.recycle()
|
||
}
|
||
return "data:image/jpeg;base64," + Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||
}
|
||
|
||
/**
|
||
* 截图有效性诊断(纯日志,不影响主流程)。
|
||
*
|
||
* 通过网格采样统计像素,输出:
|
||
* - 尺寸、采样数
|
||
* - 是否纯色(所有采样像素颜色完全一致 → 疑似空白/纯色帧)
|
||
* - 亮度均值与方差(方差≈0 → 无内容;方差高 → 有内容)
|
||
* - 深色/浅色像素占比
|
||
*
|
||
* 用于定位微信 WebView 场景:takeScreenshot 是否截到了未渲染的空白帧。
|
||
*/
|
||
private fun analyzeScreenshot(bitmap: Bitmap, packageName: String) {
|
||
try {
|
||
val w = bitmap.width
|
||
val h = bitmap.height
|
||
if (w <= 0 || h <= 0) {
|
||
Log.w(TAG, "[截图诊断] pkg=$packageName 无效尺寸: ${w}x${h}")
|
||
return
|
||
}
|
||
// 网格采样:目标约 400 个采样点,避免大图全像素扫描阻塞
|
||
val stepX = Math.max(1, w / 20)
|
||
val stepY = Math.max(1, h / 20)
|
||
var sum = 0L
|
||
var sumSq = 0L
|
||
var count = 0
|
||
var firstPixel = 0
|
||
var isSolidColor = true
|
||
var darkCount = 0 // 亮度 < 30(近黑)
|
||
var lightCount = 0 // 亮度 > 225(近白)
|
||
for (y in 0 until h step stepY) {
|
||
for (x in 0 until w step stepX) {
|
||
val pixel = bitmap.getPixel(x, y)
|
||
if (count == 0) {
|
||
firstPixel = pixel
|
||
} else if (pixel != firstPixel) {
|
||
isSolidColor = false
|
||
}
|
||
// 亮度(ITU-R BT.601):0.299R + 0.587G + 0.114B
|
||
val r = (pixel shr 16) and 0xFF
|
||
val g = (pixel shr 8) and 0xFF
|
||
val b = pixel and 0xFF
|
||
val lum = (r * 299 + g * 587 + b * 114) / 1000
|
||
sum += lum
|
||
sumSq += lum.toLong() * lum
|
||
if (lum < 30) darkCount++
|
||
if (lum > 225) lightCount++
|
||
count++
|
||
}
|
||
}
|
||
val mean = if (count > 0) sum.toDouble() / count else 0.0
|
||
val variance = if (count > 0) sumSq.toDouble() / count - mean * mean else 0.0
|
||
val darkRatio = if (count > 0) darkCount.toDouble() / count else 0.0
|
||
val lightRatio = if (count > 0) lightCount.toDouble() / count else 0.0
|
||
// 判定:纯色 或 方差<10 视为「疑似空白帧」
|
||
val likelyBlank = isSolidColor || variance < 10.0
|
||
Log.i(TAG, "[截图诊断] pkg=$packageName 尺寸=${w}x${h} 采样=$count " +
|
||
"纯色=$isSolidColor 亮度均值=${"%.1f".format(mean)} 方差=${"%.1f".format(variance)} " +
|
||
"深色占比=${"%.2f".format(darkRatio)} 浅色占比=${"%.2f".format(lightRatio)} " +
|
||
"疑似空白帧=$likelyBlank base64大小约=${(w * h) / 8}字节")
|
||
} catch (e: Exception) {
|
||
Log.w(TAG, "[截图诊断] pkg=$packageName 分析异常: ${e.message}")
|
||
}
|
||
}
|
||
|
||
/** 辅助:构造 WritableNativeMap。 */
|
||
private fun writableMapOf(vararg pairs: Pair<String, Any?>): WritableNativeMap {
|
||
val map = WritableNativeMap()
|
||
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}")
|
||
}
|
||
}
|
||
|
||
/** 递归提取无障碍节点树的全部文本(text + contentDescription)。 */
|
||
private fun dumpNodeTexts(node: AccessibilityNodeInfo?, list: MutableList<String>) {
|
||
if (node == null) return
|
||
val text = node.text?.toString()
|
||
if (!text.isNullOrBlank()) {
|
||
list.add(text)
|
||
}
|
||
// 修复:微信 8.0.52+ 混淆了 text,但部分节点仍通过 contentDescription 暴露内容
|
||
val desc = node.contentDescription?.toString()
|
||
if (!desc.isNullOrBlank() && desc != text) {
|
||
list.add(desc)
|
||
}
|
||
val childCount = node.childCount
|
||
for (i in 0 until childCount) {
|
||
val child = node.getChild(i) ?: continue
|
||
dumpNodeTexts(child, list)
|
||
child.recycle()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 增强版文本提取:先尝试 rootInActiveWindow,若为空则遍历所有窗口。
|
||
* 修复:新版 Android WebView 可能不在 activeWindow 中暴露节点,
|
||
* 需要通过 getWindows() 获取 WebView 子窗口的节点树。
|
||
*/
|
||
private fun dumpAllTexts(): List<String> {
|
||
val texts = mutableListOf<String>()
|
||
|
||
// 1. 先尝试 rootInActiveWindow(常规路径)
|
||
val rootNode = rootInActiveWindow
|
||
if (rootNode != null) {
|
||
dumpNodeTexts(rootNode, texts)
|
||
rootNode.recycle()
|
||
}
|
||
|
||
// 2. 如果 activeWindow 为空或无文本,遍历所有窗口(WebView 子窗口可能在这里)
|
||
if (texts.isEmpty()) {
|
||
try {
|
||
val allWindows = windows
|
||
for (window in allWindows) {
|
||
val wRoot = window.root ?: continue
|
||
dumpNodeTexts(wRoot, texts)
|
||
wRoot.recycle()
|
||
}
|
||
} catch (e: Exception) {
|
||
Log.w(TAG, "getWindows() 遍历失败: ${e.message}")
|
||
}
|
||
}
|
||
|
||
return texts
|
||
}
|
||
|
||
/** 递归遍历无障碍节点树,检查是否包含指定的任意一个关键字。 */
|
||
private fun findTextInNode(node: AccessibilityNodeInfo?, keywords: List<String>): Boolean {
|
||
if (node == null) return false
|
||
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
|
||
}
|