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:
fengmengqi
2026-07-28 20:57:37 +08:00
parent bf04400852
commit 6767dd538a
222 changed files with 8970 additions and 2500 deletions
@@ -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
@@ -29,16 +29,25 @@ import android.view.accessibility.AccessibilityNodeInfo
* - 横屏免打扰游戏/视频时不触发
* - ocrDoing 守卫防止重复触发
*
* 伪装说明plan.md 决策 3纯开源侧载保留无障碍伪装非应用商店分发
* 注意本服务类名在 manifest 中声明为 BillingAccessibilityService
* 伪装为系统服务包名 com.beancount.mobile.accessibility仅在侧载版本保留
* 伪装说明绕过微信 8.0.52+ 节点混淆
* 微信按 ComponentName包名/类名识别系统服务白名单TalkBackSelectToSpeak
* 系统服务不受节点混淆影响第三方服务则被混淆
* 本服务的 package + class 全部伪装为
* com.google.android.accessibility.selecttospeak.SelectToSpeakService
* 使微信将其识别为系统 SelectToSpeak 服务从而拿到未混淆的真实节点文本
*
* 伪装由 Config Pluginplugins/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 → base64JPEG 质量 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
+36 -19
View File
@@ -2,7 +2,13 @@
* 无障碍服务 Config Pluginplan.md「3.6 无障碍服务」+「决策 4」)。
*
* 在 expo prebuild 时注册 Android 无障碍服务(manifest service + xml 配置)。
* 参考 AutoAccounting 的 SelectToSpeakService 伪装机制(侧载保留,plan.md 决策 3)。
*
* 伪装机制(绕过微信 8.0.52+ 节点混淆):
* 微信按 ComponentName(包名/类名)识别系统服务白名单。早期只伪装类名不伪装包名,
* 因此被微信识别为第三方服务、对节点 text/contentDescription 做混淆。
* 现在把 7 个 kt 文件整体移到 com.google.android.accessibility.selecttospeak 包,
* 并把 Manifest 服务名写成完整全限定名,让微信将其识别为系统 SelectToSpeak 服务,
* 从而拿到未混淆的真实节点文本。
*
* 注意:withAndroidManifest 的 modResults 结构是 { manifest: { ... } }
* 操作 application 必须通过 modResults.manifest.application。
@@ -12,6 +18,16 @@ const { withAndroidManifest, withDangerousMod, withMainApplication } = require('
const fs = require('fs');
const path = require('path');
/**
* 伪装目标包名:系统 SelectToSpeak 服务的完整包名。
* 7 个 kt 文件会被复制到此包路径下,Manifest 服务名也用此包的全限定名。
*/
const FAKE_PACKAGE = 'com.google.android.accessibility.selecttospeak';
/** SelectToSpeakService 的完整 ComponentNameManifest android:name 用)。 */
const FAKE_SERVICE_NAME = `${FAKE_PACKAGE}.SelectToSpeakService`;
/** OcrTileService 的完整 ComponentName(与 Service 同包,保持同 package 引用)。 */
const FAKE_TILE_SERVICE_NAME = `${FAKE_PACKAGE}.OcrTileService`;
/**
* 从 src/domain/constants.ts 的 PAYMENT_PACKAGES 中提取包名列表。
* 这是唯一的真相来源,Kotlin 端通过此函数自动同步。
@@ -52,29 +68,28 @@ function copyDir(src, dest) {
}
}
function getAppId(config) {
return config.android?.package || 'com.example.driftledger';
}
function withAccessibilityService(config) {
const appId = getAppId(config);
const PACKAGE = `${appId}.accessibility`;
// 7 个 kt 文件全部跟随 SelectToSpeakService 伪装到 FAKE_PACKAGE 包下,
// 不再使用应用真实包名(appId)。
// 1. 复制 Kotlin 源码 + res/xml 资源到原生工程
config = withDangerousMod(config, [
'android',
async (modConfig) => {
const projectRoot = modConfig.modRequest.platformProjectRoot;
// Kotlin 源码(只复制 .kt 文件,不含 res/ 子目录)
const pkgPath = appId.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', pkgPath, 'accessibility');
// Kotlin 源码统一复制到 FAKE_PACKAGE 目录下(伪装为系统 SelectToSpeak 服务包名)。
// kt 源码里仍写 `com.beancount.mobile`,此处用正则替换为 FAKE_PACKAGE。
const fakePkgPath = FAKE_PACKAGE.replace(/\./g, '/');
const ktDest = path.join(projectRoot, 'app/src/main/java', fakePkgPath);
fs.mkdirSync(ktDest, { recursive: true });
const androidDir = path.join(__dirname, 'android');
for (const f of fs.readdirSync(androidDir)) {
if (f.endsWith('.kt')) {
let content = fs.readFileSync(path.join(androidDir, f), 'utf8');
content = content.replace(/package\s+com\.beancount\.mobile/g, `package ${appId}`);
content = content.replace(/import\s+com\.beancount\.mobile/g, `import ${appId}`);
// kt 源码 packagecom.beancount.mobile.accessibility,整体替换为 FAKE_PACKAGE。
// 注意正则要匹配到 .accessibility 后缀,否则会多出 .accessibility 导致与 Manifest 不一致。
content = content.replace(/package\s+com\.beancount\.mobile\.accessibility/g, `package ${FAKE_PACKAGE}`);
content = content.replace(/import\s+com\.beancount\.mobile\.accessibility/g, `import ${FAKE_PACKAGE}`);
fs.writeFileSync(path.join(ktDest, f), content, 'utf8');
}
}
@@ -87,7 +102,7 @@ function withAccessibilityService(config) {
// 从 constants.ts 读取包名列表,自动同步到 Kotlin
const packages = readPaymentPackagesFromConstants();
if (packages) {
const ktFile = path.join(ktDest, 'BillingAccessibilityService.kt');
const ktFile = path.join(ktDest, 'SelectToSpeakService.kt');
if (fs.existsSync(ktFile)) {
let ktContent = fs.readFileSync(ktFile, 'utf8');
// 替换 PAYMENT_PACKAGES 定义(匹配 val PAYMENT_PACKAGES = setOf( ... )
@@ -117,6 +132,8 @@ function withAccessibilityService(config) {
]);
// 2. 注册 AccessibilityBridgePackage 到 MainApplication
// 注意:AccessibilityBridgePackage 跟随其他 kt 文件一起被复制到 FAKE_PACKAGE 下,
// 因此 import 路径要用 FAKE_PACKAGE 而非 PACKAGE(应用包名)。
config = withMainApplication(config, (modConfig) => {
let content = modConfig.modResults.contents;
@@ -124,7 +141,7 @@ function withAccessibilityService(config) {
content = content.replace(/^import\s+[\w.]+\.AccessibilityBridgePackage\s*$/gm, '');
content = content.replace(
/^(package\s+[\w.]+;?\s*)$/m,
`$1\nimport ${PACKAGE}.AccessibilityBridgePackage`,
`$1\nimport ${FAKE_PACKAGE}.AccessibilityBridgePackage`,
);
// 2c. 在 getPackages() 的 .apply {} 块里注入 add(AccessibilityBridgePackage())
@@ -160,10 +177,10 @@ function withAccessibilityService(config) {
manifest['uses-permission'].push({ $: { 'android:name': overlayPerm } });
}
// 1. 添加无障碍服务声明
// 1. 添加无障碍服务声明(服务名使用伪装包的全限定名,以匹配微信系统服务白名单)
const serviceNode = {
$: {
'android:name': `${PACKAGE}.BillingAccessibilityService`,
'android:name': FAKE_SERVICE_NAME,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '浮记-账单识别',
'android:exported': 'false',
@@ -188,7 +205,7 @@ function withAccessibilityService(config) {
app.service = [];
}
const exists = app.service.some(
s => s.$['android:name'] === `${PACKAGE}.BillingAccessibilityService`
s => s.$['android:name'] === FAKE_SERVICE_NAME
);
if (!exists) {
app.service.push(serviceNode);
@@ -197,7 +214,7 @@ function withAccessibilityService(config) {
// 3. 添加 OcrTileService 声明(快速设置磁贴,plan.md「3.11」)
const tileServiceNode = {
$: {
'android:name': `${PACKAGE}.OcrTileService`,
'android:name': FAKE_TILE_SERVICE_NAME,
'android:label': 'OCR 记账',
'android:icon': '@android:drawable/ic_menu_camera',
'android:permission': 'android.permission.BIND_QUICK_SETTINGS_TILE',
@@ -208,7 +225,7 @@ function withAccessibilityService(config) {
}],
};
const tileExists = app.service.some(
s => s.$['android:name'] === `${PACKAGE}.OcrTileService`
s => s.$['android:name'] === FAKE_TILE_SERVICE_NAME
);
if (!tileExists) {
app.service.push(tileServiceNode);