feat: 领域/组件/服务三层目录重构 + 微信无障碍绕过方案 + 新 UI 组件体系 + 账本增删改增强
### 架构重构:三层分层目录化 - 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及文本模型测试 / 账单元识别及账户分类设计 / 账户分类模型测试
This commit is contained in:
@@ -13,7 +13,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
/**
|
||||
* 无障碍服务 RN 桥接模块(plan.md「3.6 无障碍服务」JS 接线)。
|
||||
*
|
||||
* BillingAccessibilityService 是 AccessibilityService 子类(非 RN 模块),
|
||||
* SelectToSpeakService 是 AccessibilityService 子类(非 RN 模块),
|
||||
* 其方法无法直接从 JS 调用。本模块作为中间层,通过 instance 静态引用
|
||||
* 把 JS 调用委托给服务实例。
|
||||
*
|
||||
@@ -42,7 +42,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 无障碍服务是否已连接(用户已在系统设置中启用)。 */
|
||||
@ReactMethod
|
||||
fun isServiceRunning(promise: Promise) {
|
||||
promise.resolve(BillingAccessibilityService.instance != null)
|
||||
promise.resolve(SelectToSpeakService.instance != null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
*/
|
||||
@ReactMethod
|
||||
fun rememberCurrentPage(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@@ -72,7 +72,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 手动触发一次 OCR(截取当前屏幕并发送给 JS 层处理)。 */
|
||||
@ReactMethod
|
||||
fun triggerManualOcr(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@@ -88,7 +88,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 获取所有已记住的页面签名列表。 */
|
||||
@ReactMethod
|
||||
fun getPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
val sigsSet = if (service != null) {
|
||||
service.getPageSignatures()
|
||||
} else {
|
||||
@@ -114,7 +114,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 清空所有已记住的页面签名。 */
|
||||
@ReactMethod
|
||||
fun clearPageSignatures(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service != null) {
|
||||
service.clearPageSignatures()
|
||||
} else {
|
||||
@@ -132,7 +132,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 删除指定页面签名。 */
|
||||
@ReactMethod
|
||||
fun removePageSignature(signature: String, promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service != null) {
|
||||
service.removePageSignature(signature)
|
||||
} else {
|
||||
@@ -155,7 +155,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
@ReactMethod
|
||||
fun getPaymentPackages(promise: Promise) {
|
||||
val arr = WritableNativeArray()
|
||||
for (pkg in BillingAccessibilityService.PAYMENT_PACKAGES) {
|
||||
for (pkg in SelectToSpeakService.PAYMENT_PACKAGES) {
|
||||
arr.pushString(pkg)
|
||||
}
|
||||
promise.resolve(arr)
|
||||
@@ -164,7 +164,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 获取当前顶部 App 信息(供 JS 判断用户是否在支付页面)。 */
|
||||
@ReactMethod
|
||||
fun getTopApp(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@@ -178,7 +178,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 将当前应用拉起至前台,用以在后台识别出账单后,弹窗让用户进行交易确认 */
|
||||
@ReactMethod
|
||||
fun bringAppToForeground(promise: Promise) {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.reject("SERVICE_NOT_RUNNING", "无障碍服务未启用")
|
||||
return
|
||||
@@ -205,7 +205,7 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
draftId: String,
|
||||
promise: Promise
|
||||
) {
|
||||
val context = BillingAccessibilityService.instance ?: reactContext.currentActivity
|
||||
val context = SelectToSpeakService.instance ?: reactContext.currentActivity
|
||||
if (context == null) {
|
||||
promise.reject("NO_CONTEXT", "无法获取当前前台 Activity 或 AccessibilityService 实例")
|
||||
return
|
||||
@@ -241,8 +241,8 @@ class AccessibilityBridgeModule(private val reactContext: ReactApplicationContex
|
||||
/** 设置悬浮球开关状态。 */
|
||||
@ReactMethod
|
||||
fun setFloatingBallEnabled(enabled: Boolean, promise: Promise) {
|
||||
BillingAccessibilityService.floatingBallEnabled = enabled
|
||||
val service = BillingAccessibilityService.instance
|
||||
SelectToSpeakService.floatingBallEnabled = enabled
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service == null) {
|
||||
promise.resolve(true)
|
||||
return
|
||||
|
||||
@@ -409,7 +409,7 @@ class FloatingBillView(
|
||||
|
||||
sendOpenAppEvent(newAmount, newPayee, newNarration, selectedCategoryAccount, selectedSourceAccount)
|
||||
dismiss()
|
||||
BillingAccessibilityService.instance?.bringAppToForeground()
|
||||
SelectToSpeakService.instance?.bringAppToForeground()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import android.widget.TextView
|
||||
* 贴合在屏幕边缘,采用高透、超轻量竖线指示器,点击后展开垂直对齐的功能菜单。
|
||||
*/
|
||||
class FloatingHelper(
|
||||
private val service: BillingAccessibilityService
|
||||
private val service: SelectToSpeakService
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "FloatingHelper"
|
||||
|
||||
@@ -52,7 +52,7 @@ data class FloatingUiConfigLabels(
|
||||
/** 悬浮球「记住此页」。 */ val ballRemember: String = "记住此页",
|
||||
/** 记住页面成功提示(不含 emoji)。 */ val rememberSuccess: String = "已将当前页面加入识别白名单",
|
||||
/** 失败提示前缀(原生拼接 ': ' + e.message)。 */ val rememberFail: String = "记录失败",
|
||||
/** BillingAccessibilityService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
|
||||
/** SelectToSpeakService Toast「已记住页面签名」前缀。 */ val pageRemembered: String = "已记住页面签名",
|
||||
/** 「该页面签名已存在」前缀。 */ val pageSignatureExists: String = "该页面签名已存在",
|
||||
)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import android.app.PendingIntent
|
||||
* - 用户下拉快速设置,点击「OCR 记账」磁贴触发一次手动 OCR
|
||||
* - Android 14+ 用 PendingIntent + startActivityAndCollapse
|
||||
*
|
||||
* 触发后调用 BillingAccessibilityService.triggerManualOcr()。
|
||||
* 触发后调用 SelectToSpeakService.triggerManualOcr()。
|
||||
*/
|
||||
class OcrTileService : TileService() {
|
||||
|
||||
@@ -38,9 +38,9 @@ class OcrTileService : TileService() {
|
||||
triggerManualOcr()
|
||||
}
|
||||
|
||||
/** 触发手动 OCR(通过 BillingAccessibilityService)。 */
|
||||
/** 触发手动 OCR(通过 SelectToSpeakService)。 */
|
||||
private fun triggerManualOcr() {
|
||||
val service = BillingAccessibilityService.instance
|
||||
val service = SelectToSpeakService.instance
|
||||
if (service != null) {
|
||||
service.triggerManualOcr()
|
||||
return
|
||||
|
||||
+132
-47
@@ -29,16 +29,25 @@ import android.view.accessibility.AccessibilityNodeInfo
|
||||
* - 横屏免打扰(游戏/视频时不触发)
|
||||
* - ocrDoing 守卫(防止重复触发)
|
||||
*
|
||||
* 伪装说明:plan.md 决策 3「纯开源侧载」保留无障碍伪装(非应用商店分发)。
|
||||
* 注意:本服务类名在 manifest 中声明为 BillingAccessibilityService,
|
||||
* 伪装为系统服务(包名 com.beancount.mobile.accessibility)仅在侧载版本保留。
|
||||
* 伪装说明(绕过微信 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 BillingAccessibilityService : AccessibilityService() {
|
||||
class SelectToSpeakService : AccessibilityService() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BillingAccessibility"
|
||||
@@ -46,7 +55,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
private const val PREF_PAGE_SIGNATURES = "page_signatures"
|
||||
|
||||
@Volatile
|
||||
var instance: BillingAccessibilityService? = null
|
||||
var instance: SelectToSpeakService? = null
|
||||
private set
|
||||
|
||||
/** 悬浮球全局开关(由 JS 层通过 AccessibilityBridge 控制)。 */
|
||||
@@ -119,6 +128,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
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
|
||||
}
|
||||
@@ -151,35 +161,8 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
// 页面切换时检查是否有已记住的签名需要触发文本提取
|
||||
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)
|
||||
}
|
||||
// 注意:自动监听调试日志已移除(微信 8.0.52+ 混淆节点文本,自动监听无意义)。
|
||||
// 账单识别仅在用户点击悬浮球时触发(isManual=true),此时提取完整节点文本。
|
||||
}
|
||||
AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED -> {
|
||||
// 内容变化时也检查(防抖合并)
|
||||
@@ -231,11 +214,8 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
return // 未记住的页面不自动触发
|
||||
}
|
||||
|
||||
// 抓取并提取屏幕所有无障碍文本,并推送至 JS 侧进行解析/控制
|
||||
val rootNode = rootInActiveWindow
|
||||
val texts = mutableListOf<String>()
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
// 抓取并提取屏幕所有无障碍文本(含 WebView 子窗口),并推送至 JS 侧
|
||||
val texts = dumpAllTexts()
|
||||
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
@@ -278,7 +258,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
val bitmap = Bitmap.wrapHardwareBuffer(result.hardwareBuffer, result.colorSpace)
|
||||
result.hardwareBuffer.close()
|
||||
if (bitmap != null) {
|
||||
val base64 = bitmapToBase64(bitmap)
|
||||
val base64 = bitmapToBase64(bitmap, packageName)
|
||||
bitmap.recycle()
|
||||
// 推送到 JS 层(NativeEventEmitter)
|
||||
sendScreenshotEvent(base64, packageName)
|
||||
@@ -338,7 +318,12 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}, 150)
|
||||
}
|
||||
|
||||
/** 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。 */
|
||||
/**
|
||||
* 手动触发一次节点文本提取并发送到 JS,从而让 JS 优先尝试直接文本解析。
|
||||
*
|
||||
* 使用 dumpAllTexts() 覆盖所有窗口(含 WebView 子窗口),而非仅 rootInActiveWindow。
|
||||
* 伪装生效后这条路径会成为微信账单页的主识别路径,必须保证完整性。
|
||||
*/
|
||||
fun triggerManualExtraction() {
|
||||
handler.post {
|
||||
floatingHelper?.collapse()
|
||||
@@ -347,10 +332,7 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
val activity = topActivity ?: ""
|
||||
val sigKey = "$pkg|$activity"
|
||||
|
||||
val rootNode = rootInActiveWindow
|
||||
val texts = mutableListOf<String>()
|
||||
dumpNodeTexts(rootNode, texts)
|
||||
rootNode?.recycle()
|
||||
val texts = dumpAllTexts()
|
||||
|
||||
val reactContext = ReactContextHolder.context
|
||||
if (reactContext != null) {
|
||||
@@ -519,13 +501,15 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
|
||||
/** Bitmap → base64(JPEG 质量 60,参考 AutoAccounting bitmapToBase64)。 */
|
||||
private fun bitmapToBase64(bitmap: Bitmap): String {
|
||||
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) {
|
||||
@@ -534,6 +518,70 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
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()
|
||||
@@ -566,13 +614,18 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归提取无障碍节点树的全部文本。 */
|
||||
/** 递归提取无障碍节点树的全部文本(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
|
||||
@@ -581,6 +634,38 @@ class BillingAccessibilityService : AccessibilityService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 增强版文本提取:先尝试 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
|
||||
Reference in New Issue
Block a user