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>() 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() 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) } } }