diff --git a/android/app/src/main/kotlin/com/tntlikely/beecount/MainActivity.kt b/android/app/src/main/kotlin/com/tntlikely/beecount/MainActivity.kt index 14ad2b40..e540f786 100644 --- a/android/app/src/main/kotlin/com/tntlikely/beecount/MainActivity.kt +++ b/android/app/src/main/kotlin/com/tntlikely/beecount/MainActivity.kt @@ -20,10 +20,12 @@ import io.flutter.plugin.common.MethodChannel class MainActivity: FlutterActivity() { private val CHANNEL = "notification_channel" + private val RECORD_CHANNEL = "com.beecount.api/broadcast" private val INSTALL_CHANNEL = "com.tntlikely.beecount/install" private val SCREENSHOT_CHANNEL = "com.tntlikely.beecount/screenshot" private val LOGGER_CHANNEL = "com.beecount.logger" private val SHARE_CHANNEL = "com.tntlikely.beecount/share" + private val AUTO_BILL_ACTION = "com.tntlikely.beecount.AUTO_BILLING" private var screenshotObserver: ScreenshotObserver? = null @@ -136,6 +138,55 @@ class MainActivity: FlutterActivity() { android.util.Log.e("MainActivity", "==========================================") android.util.Log.e("MainActivity", "configureFlutterEngine 被调用!!!") android.util.Log.e("MainActivity", "==========================================") + + // 分别建立连接 + val recordChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, RECORD_CHANNEL) + // 1. 定义广播接收器 (监听来自 Xposed 或 ADB 的广播) + val receiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context?, intent: android.content.Intent?) { + val action = intent?.action + println("📡 [Native] 收到广播: $action") + + if (action == AUTO_BILL_ACTION) { + + // 【兼容取值】优先取 amount (BeeCount),没有则取 money (钱迹格式) + val rawAmount = intent.extras?.get("amount") ?: intent.extras?.get("money") + val amount = rawAmount.toString().replace(",", "").toDoubleOrNull() ?: 0.0 + + // 【兼容备注】优先取备注,备注为空则取分类名 + val rawRemark = intent.getStringExtra("remark") ?: "" + val rawCateName = intent.getStringExtra("cateName") ?: "" + val finalRemark = if (rawRemark.isNotEmpty()) rawRemark else rawCateName + + val type = intent.getIntExtra("type", 0) + val rawAccount = intent.getStringExtra("account") ?: "" + + val args = mapOf( + "amount" to amount, + "remark" to finalRemark, + "category" to rawCateName, + "account" to rawAccount, + "type" to if (type == 1) "income" else "expense", + "timestamp" to System.currentTimeMillis() + ) + + // 通过之前定义的 recordChannel 发送 + recordChannel.invokeMethod("addTransaction", args) + } + } + } + + // 2. 注册过滤器 + val filter = android.content.IntentFilter() + filter.addAction(AUTO_BILL_ACTION) + + + // 3. 注册接收器(适配 Android 14+ 安全要求) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, filter, android.content.Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(receiver, filter) + } // 日志桥接的MethodChannel val loggerChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, LOGGER_CHANNEL) diff --git "a/docs/Xposed/ADB\347\244\272\344\276\213.md" "b/docs/Xposed/ADB\347\244\272\344\276\213.md" new file mode 100644 index 00000000..638ce340 --- /dev/null +++ "b/docs/Xposed/ADB\347\244\272\344\276\213.md" @@ -0,0 +1,54 @@ +# 🐝 BeeCount 自动化记账 API 测试指南 + +## 1. 核心配置 +- **广播 Action**: `com.tntlikely.beecount.AUTO_BILLING` +- **目标包名**: `com.tntlikely.beecount.dev.debug` + +## 2. 参数说明 +- `amount`: 金额 (支持 "1,234.56") +- `account`: 账户名 (需与 App 内一致) +- `type`: 0 为支出, 1 为收入 +- `remark`: 备注 (可选) + +--- + +## 3. 支付宝测试 (Alipay) + +### 🔴 支出 +adb shell am broadcast -a com.tntlikely.beecount.AUTO_BILLING -p com.tntlikely.beecount.dev.debug --es amount "12.80" --es account "支付宝" --ei type 0 --es remark "便利店消费" + +### 🔵 收入 +adb shell am broadcast -a com.tntlikely.beecount.AUTO_BILLING -p com.tntlikely.beecount.dev.debug --es amount "100.00" --es account "支付宝" --ei type 1 --es remark "余额宝收益" + +--- + +## 4. 微信测试 (WeChat) + +### 🔴 支出 +adb shell am broadcast -a com.tntlikely.beecount.AUTO_BILLING -p com.tntlikely.beecount.dev.debug --es amount "8.50" --es account "微信" --ei type 0 --es remark "早饭包子" + +### 🔵 收入 +adb shell am broadcast -a com.tntlikely.beecount.AUTO_BILLING -p com.tntlikely.beecount.dev.debug --es amount "88.88" --es account "微信" --ei type 1 --es remark "收到红包" + +--- + +## 5. 现金测试 (Cash) + +### 🔴 支出 +adb shell am broadcast -a com.tntlikely.beecount.AUTO_BILLING -p com.tntlikely.beecount.dev.debug --es amount "10.00" --es account "现金" --ei type 0 --es remark "打车零钱" + +### 🔵 收入 +adb shell am broadcast -a com.tntlikely.beecount.AUTO_BILLING -p com.tntlikely.beecount.dev.debug --es amount "50.00" --es account "现金" --ei type 1 --es remark "捡到钱了" + +## 运行效果演示 + +以下是功能实现的实际运行截图: + +### 运行效果演示 + +| 1. 支付宝收入测试 | 2. 支付宝支出测试 | +| :---: | :---: | +| ![记账结果](./docs/Xposed/示例3.jpg) | ![记账结果](./docs/Xposed/示例3.jpg) | + +### 3. 手机端最终入账展示 +![记账结果](./docs/Xposed/示例3.jpg) \ No newline at end of file diff --git "a/docs/Xposed/\347\244\272\344\276\2131.png" "b/docs/Xposed/\347\244\272\344\276\2131.png" new file mode 100644 index 00000000..5fdd62a0 Binary files /dev/null and "b/docs/Xposed/\347\244\272\344\276\2131.png" differ diff --git "a/docs/Xposed/\347\244\272\344\276\2132.png" "b/docs/Xposed/\347\244\272\344\276\2132.png" new file mode 100644 index 00000000..d3aa81dc Binary files /dev/null and "b/docs/Xposed/\347\244\272\344\276\2132.png" differ diff --git "a/docs/Xposed/\347\244\272\344\276\2133.jpg" "b/docs/Xposed/\347\244\272\344\276\2133.jpg" new file mode 100644 index 00000000..fae9e0a2 Binary files /dev/null and "b/docs/Xposed/\347\244\272\344\276\2133.jpg" differ diff --git a/lib/services/automation/auto_billing_service.dart b/lib/services/automation/auto_billing_service.dart index 7c0e5cac..963f359f 100644 --- a/lib/services/automation/auto_billing_service.dart +++ b/lib/services/automation/auto_billing_service.dart @@ -343,6 +343,7 @@ class AutoBillingService { /// [text] 快捷指令传递的识别文本 /// [showNotification] 是否显示通知(默认true) /// 返回:交易记录ID,失败返回null + /// 核心:直接处理文本并自动记账 Future processText( String text, { bool showNotification = true, @@ -362,8 +363,38 @@ class AutoBillingService { ); } - // 直接解析文本(无需OCR) - final ocrResult = _ocrService.parsePaymentText(text); + // 🔥【核心修改】:支持结构化暗号解析 (XPOSED:金额|备注|分类) + OcrResult ocrResult; + String? manualCategory; // 记录传过来的分类名 + String? typeStr; + String? manualAccount; + if (text.startsWith("XPOSED:")) { + print("🚀 [精准模式] 检测到 Xposed 结构化数据"); + final content = text.substring(7); // 去掉 "XPOSED:" + final parts = content.split('|'); + + // 解析格式:金额|备注|分类|收支方向|账户 + final double? amount = parts.length > 0 ? double.tryParse(parts[0]) : null; + final String? note = (parts.length > 1 && parts[1].isNotEmpty) ? parts[1] : null; + manualCategory = (parts.length > 2 && parts[2].isNotEmpty) ? parts[2] : null; + typeStr = parts.length > 3 ? parts[3] : '0'; + manualAccount = parts.length > 4 && parts[4].isNotEmpty ? parts[4] : null; + ocrResult = OcrResult( + amount: amount, + note: note, + time: DateTime.now(), + rawText: text, + allNumbers: [], + suggestedCategoryId: null, + ); + if (typeStr == '1' || typeStr == 'income') { + // 这里我们可以在创建交易前做个标记,或者直接在这里把金额变成负数(如果你的底层逻辑支持) + print("💰 检测到收入信号,准备记入收入"); + } + } else { + // 普通模式:走原有的正则解析逻辑 + ocrResult = _ocrService.parsePaymentText(text); + } if (ocrResult.amount == null) { print('❌ 未能识别出金额'); @@ -379,32 +410,30 @@ class AutoBillingService { print('✅ 识别成功: 金额=${ocrResult.amount}, 备注=${ocrResult.note}'); - // 更新通知状态 - if (showNotification) { - await _showNotification( - id: notificationId, - title: '✅ 识别成功', - body: '正在创建交易记录...', - ); - } + // 获取分类逻辑 + // 1. 根据 typeStr 动态判定搜索方向 + // typeStr 是前面从 parts[3] 解析出来的结果 + final isIncome = (typeStr == '1' || typeStr == 'income'); + final String searchDirection = isIncome ? 'income' : 'expense'; + + print('🔍 [自动化判断] 记账方向: $searchDirection'); - // 获取分类并创建交易 + // 2. 获取分类逻辑 (注意:括号里改成了变量 searchDirection) final repo = _container.read(repositoryProvider); - final topLevelCategories = await repo.getTopLevelCategories('expense'); + final topLevelCategories = await repo.getTopLevelCategories(searchDirection); + final allCategories = []; allCategories.addAll(topLevelCategories); - // 获取所有子分类 for (final category in topLevelCategories) { final subCategories = await repo.getSubCategories(category.id); allCategories.addAll(subCategories); } - // 过滤出可用分类(排除有子分类的父分类) final categories = CategoryHierarchy.getUsableCategories(allCategories); - + // 匹配分类:优先使用传过来的分类名进行智能匹配 final suggestedCategoryId = CategoryMatcher.smartMatch( merchant: ocrResult.note, - fullText: ocrResult.rawText, + fullText: manualCategory ?? ocrResult.rawText, // 如果有手动分类,优先按它匹配 categories: categories, ); @@ -418,58 +447,52 @@ class AutoBillingService { ); // 创建交易记录 - final txId = await _createTransaction(resultWithCategory); + // 创建交易记录:多传一个 typeStr 参数 + final txId = await _createTransaction( + resultWithCategory, + typeOverride: typeStr, // 👈 把刚才解析的 '1' 传下去 + accountOverride: manualAccount, + ); if (txId != null) { - // 刷新统计信息 _container.read(statsRefreshProvider.notifier).state++; - print('✅ 交易创建成功: id=$txId'); if (showNotification) { await _showNotification( id: notificationId, title: '✅ 记账成功', - body: '已自动创建支出记录: ¥${ocrResult.amount}', + body: '金额: ¥${ocrResult.amount}${ocrResult.note != null ? ' (${ocrResult.note})' : ''}', ); } return txId; - } else { - print('❌ 交易创建失败'); - if (showNotification) { - await _showNotification( - id: notificationId, - title: '❌ 创建失败', - body: '无法创建交易记录', - ); - } - return null; } + return null; } catch (e) { print('❌ [AutoBilling] 文本处理失败: $e'); - if (showNotification) { - await _showNotification( - id: 1002, - title: '❌ 处理失败', - body: '错误: $e', - ); - } return null; } finally { - final totalElapsed = - DateTime.now().millisecondsSinceEpoch - totalStartTime; + final totalElapsed = DateTime.now().millisecondsSinceEpoch - totalStartTime; print('⏱️ [性能] 文本处理完成, 总耗时=${totalElapsed}ms'); } } /// 创建交易记录 - /// [billingTypes] 记账方式列表,用于添加标签 - /// [autoAddTags] 是否自动添加标签 Future _createTransaction( OcrResult result, { List? billingTypes, bool autoAddTags = true, + String? typeOverride, // 1. 接收从管道传来的类型信号 + String? accountOverride, }) async { try { - // 获取当前账本ID(优先从Provider读取,失败则从SharedPreferences读取,最后从数据库获取默认账本) + // --- 🔥 逻辑注入:强制指定类型 --- + String? forceType; + if (typeOverride == '1' || typeOverride == 'income') { + forceType = 'income'; + } else if (typeOverride == '0' || typeOverride == 'expense') { + forceType = 'expense'; + } + + // 2. 获取当前账本ID(保留你原有的三方案逻辑) int? ledgerId; // 方案1: 尝试从Provider读取 @@ -497,7 +520,6 @@ class AutoBillingService { if (ledgers.isNotEmpty) { ledgerId = ledgers.first.id; print('✅ 从数据库获取默认账本ID: $ledgerId'); - // 保存到SharedPreferences供下次使用 final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_ledgerIdKey, ledgerId!); } @@ -508,22 +530,16 @@ class AutoBillingService { return null; } - print('📝 准备创建交易: ledgerId=$ledgerId'); - - // 使用共享的BillCreationService创建交易 + // 3. 初始化记账核心服务 final repo = _container.read(repositoryProvider); final billCreationService = BillCreationService(repo); - // 准备备注 - String? note; - if (result.note != null) { - note = result.note!; - } - - // 获取 l10n(使用系统语言设置) + // 4. 准备备注与语言环境 + String? note = result.note; final systemLocale = PlatformDispatcher.instance.locale; final l10n = lookupAppLocalizations(systemLocale); + // 5. ⚠️ 真正执行创建(注意最后多了一个 type 参数) final transactionId = await billCreationService.createBillTransaction( result: result, ledgerId: ledgerId, @@ -531,6 +547,8 @@ class AutoBillingService { billingTypes: billingTypes, l10n: l10n, autoAddTags: autoAddTags, + type: forceType, // 🔥 这里把强制指定的类型传进去,解决“反了”的问题 + accountName: accountOverride,// 👈 核心修改 2:把解析出来的“微信/支付宝”传下去 ); if (transactionId != null) { @@ -541,6 +559,20 @@ class AutoBillingService { logger.warning('AutoBilling', '创建交易记录失败'); } + // 🌟 核心修复:记账成功后,强制失效相关的缓存和状态 + if (transactionId != null) { + // 1. 刷新首页交易缓存(解决“微信/支付宝”显示不出来的关键) + _container.invalidate(cachedTransactionsProvider); + + // 2. 刷新账户统计(让首页顶部的余额、资产数字立刻变动) + _container.invalidate(allAccountStatsProvider); + + // 3. 刷新总计统计(支出/收入总和) + _container.invalidate(allAccountsTotalStatsProvider); + + print('🔔 [AutoBilling] 已发出 UI 刷新指令'); + } + return transactionId; } catch (e) { print('❌ 创建交易记录失败: $e'); @@ -548,7 +580,6 @@ class AutoBillingService { rethrow; } } - /// 显示通知 Future _showNotification({ required int id, @@ -577,4 +608,4 @@ class AutoBillingService { void dispose() { _ocrService.dispose(); } -} +} \ No newline at end of file diff --git a/lib/services/billing/bill_creation_service.dart b/lib/services/billing/bill_creation_service.dart index 54f6feea..9bebb848 100644 --- a/lib/services/billing/bill_creation_service.dart +++ b/lib/services/billing/bill_creation_service.dart @@ -97,7 +97,28 @@ class BillCreationService { OcrResult result, int ledgerId, { String transactionType = 'expense', + String? accountName, }) async { + // 只要外部传了 accountName,我们就直接去匹配,不再往下走 AI 识别 + if (accountName != null && accountName.isNotEmpty) { + final repository = repo; + final allAccounts = await repository.getAllAccounts(); + try { + // 在所有账户中寻找匹配项(包含关系匹配) + final forcedAccount = allAccounts.firstWhere( + (a) => a.name.contains(accountName) || accountName.contains(a.name), + ); + logger.debug(_tag, '[精准匹配] 🎯 强制锁定账户: ${forcedAccount.name}(ID:${forcedAccount.id})'); + + // 关键点:直接返回 ID,后面所有的逻辑(AI识别、默认账户等)都会被跳过 + return forcedAccount.id; + } catch (e) { + logger.warning(_tag, '[精准匹配] ❌ 资产库中找不到名为 "$accountName" 的账户'); + // 如果没找到,代码会继续往下走原本的 AI 识别流程 + } + } + + // 1. 检查账户功能是否启用 final prefs = await SharedPreferences.getInstance(); final accountFeatureEnabled = prefs.getBool('account_feature_enabled') ?? true; @@ -232,18 +253,32 @@ class BillCreationService { List? billingTypes, AppLocalizations? l10n, bool autoAddTags = true, + String? type, // 🟢 增加这一行,让它能接收“收入/支出”指令 + String? accountName, }) async { // 1. 验证金额 if (result.amount == null || result.amount!.abs() <= 0) { return null; } +// 🌟 第一步:先定义 repository (把原本在后面的定义挪到这里) + final repository = repo; + + // 🌟 第二步:获取账户列表 + final allAccounts = await repository.getAllAccounts(); + // 2. 确定交易类型 - // 优先级:AI识别类型 > 关键字识别 > 金额正负推断 > 默认支出 + // 🔥 新的优先级:强制指定类型 > AI识别类型 > 关键字识别 ... String transactionType; - String typeSource = ''; // 记录类型判断依据 + String typeSource = ''; - if (result.aiType != null && result.aiType!.isNotEmpty) { + // 🌟 第一步:首先检查外部是否传了“强制指令” (就是我们从管道传进来的那个 type) + if (type != null && type.isNotEmpty) { + transactionType = type; // 如果传了 'income',直接用! + typeSource = '强制指定'; + } + + else if (result.aiType != null && result.aiType!.isNotEmpty) { transactionType = result.aiType!; typeSource = 'AI识别'; } else { @@ -297,8 +332,9 @@ class BillCreationService { logger.debug(_tag, '[类型判断] $typeSource → ${transactionType == 'income' ? '收入' : '支出'}'); + + // 3. 查询对应类型的所有分类 - final repository = repo; final topLevelCategories = await repository.getTopLevelCategories(transactionType); final allCategories = []; allCategories.addAll(topLevelCategories); @@ -319,15 +355,43 @@ class BillCreationService { categoryId = await _getFallbackCategoryId(categories, transactionType); } - // 5. 匹配账户(在账户功能启用的前提下,未匹配时使用默认账户) - final accountId = await matchAccount(result, ledgerId, transactionType: transactionType); + // 5. 匹配账户(调整后的极简版) + int? accountId; // 👈 1. 先不给值 + final String? searchContent = note ?? result.note; + + // 🌟 优先通过备注关键词匹配(你的逻辑) + if (searchContent != null) { + final sortedAccounts = allAccounts.toList() + ..sort((a, b) => b.name.length.compareTo(a.name.length)); // 解决 Cash 截胡问题 + + for (var account in sortedAccounts) { + if (searchContent.toLowerCase().contains(account.name.toLowerCase())) { + accountId = account.id; + logger.debug(_tag, '🎯 [步骤5] 备注精准匹配账户: ${account.name}'); + break; + } + } + } + + // 🌟 如果备注没匹配到,再走原生逻辑(AI识别 + 默认账户) + if (accountId == null) { + accountId = await matchAccount( + result, + ledgerId, + transactionType: transactionType, + accountName: accountName, // 👈 核心修改:把接力棒传给 matchAccount + ); + } + + // 如果最后还是 null,由底层逻辑或默认账户处理 + // 这样保证了 accountId 在进入第 7 步(日志)前是有值的 // 6. 确定交易时间(优先使用识别的时间,否则使用当前时间) final DateTime happenedAt = result.time ?? DateTime.now(); + // 7. 获取分类和账户名称(用于日志) String? categoryName; - String? accountName; if (categoryId != null) { final category = categories.where((c) => c.id == categoryId).firstOrNull; categoryName = category?.name; @@ -337,8 +401,29 @@ class BillCreationService { accountName = account?.name; } - // 8. 确定最终备注(优先使用 result.note,其次使用参数 note) - final finalNote = result.note ?? note; + // 8. 确定最终备注并进行修剪 + String? finalNote = result.note ?? note; + + if (finalNote != null) { + // 🌟 1. 剔除常见的支付前缀(如“微信支付:”、“支付宝支付:”) + // 使用正则匹配,支持中文冒号和英文冒号 + finalNote = finalNote.replaceAll(RegExp(r'^(微信支付|支付宝支付|支付|收款)[::]\s*'), ''); + + // 🌟 2. 进一步优化:如果备注里还包含账户名,也把它剔除(可选) + // 比如将 "支付宝转账收款" 简化为 "转账收款" + if (accountId != null) { + final currentAccount = allAccounts.firstWhereOrNull((a) => a.id == accountId); + if (currentAccount != null) { + // 只剔除开头的账户名,避免误删中间的有效信息 + finalNote = finalNote.replaceFirst(currentAccount.name, '').trim(); + } + } + + // 🌟 3. 清理掉可能残余的起始符号 + if (finalNote.startsWith(':') || finalNote.startsWith(':')) { + finalNote = finalNote.substring(1).trim(); + } + } // 9. 使用Repository创建交易 final finalAmount = result.amount!.abs(); diff --git a/lib/services/xposed_service.dart b/lib/services/xposed_service.dart new file mode 100644 index 00000000..9746378a --- /dev/null +++ b/lib/services/xposed_service.dart @@ -0,0 +1,61 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter/material.dart'; +import 'package:beecount/services/automation/auto_billing_service.dart'; + +class XposedService { + static final XposedService _instance = XposedService._internal(); + factory XposedService() => _instance; + XposedService._internal(); + + static const platform = MethodChannel('com.beecount.api/broadcast'); + AutoBillingService? _autoBillingService; + + void init(WidgetRef ref, BuildContext context) { + print("🚀 自动记账服务启动 (管道模式)..."); + + platform.setMethodCallHandler((call) async { + if (call.method == 'addTransaction') { + print("📞 [XposedService] 收到 Native 调用"); + + try { + // 🟢 补全:初始化发动机 (之前就是少了这一段) + if (_autoBillingService == null) { + print("🛠️ [XposedService] 正在初始化 AutoBillingService..."); + // 通过 context 获取全局的 ProviderContainer + _autoBillingService = AutoBillingService(ProviderScope.containerOf(context)); + } + + final Map args = call.arguments as Map; + + final String amountStr = args['amount']?.toString() ?? '0'; + final String note = args['remark']?.toString() ?? ''; + final String category = args['category']?.toString() ?? ''; + final String type = args['type']?.toString() ?? 'expense'; + final String account = args['account']?.toString() ?? ''; + + String finalNote = note.isNotEmpty ? note : category; + + // 构造暗号 + final String commandText = "XPOSED:$amountStr|$finalNote|$category|$type|$account"; + print("📥 [API匹配] 协议转发: $commandText"); + + // 🟢 补全:调用逻辑 + if (_autoBillingService != null) { + print("🚀 [XposedService] 指令已分发至 AutoBillingService"); + await _autoBillingService!.processText(commandText, showNotification: true); + } else { + print("❌ [XposedService] 初始化失败,无法处理指令"); + } + + return "Success"; + + } catch (e) { + print("❌ [XposedService] 异常: $e"); + return "Error: $e"; + } + } + return null; + }); + } +} \ No newline at end of file