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() 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() 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 { 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): 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) { 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 { val texts = mutableListOf() // 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): 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 }