diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationBridgeService.kt b/android/app/src/main/java/com/echo/app/notification/NotificationBridgeService.kt index a27f4e2..c3229c1 100644 --- a/android/app/src/main/java/com/echo/app/notification/NotificationBridgeService.kt +++ b/android/app/src/main/java/com/echo/app/notification/NotificationBridgeService.kt @@ -4,13 +4,14 @@ import android.app.Notification import android.content.Intent import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification +import android.util.Log import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone import org.json.JSONObject -// Android notification listener: persist notifications to local storage and notify app via broadcast +// Android notification listener: 在原生层持久化通知到 SQLite,并通过广播通知前端 class NotificationBridgeService : NotificationListenerService() { override fun onNotificationPosted(sbn: StatusBarNotification) { val notification = sbn.notification ?: return @@ -21,6 +22,7 @@ class NotificationBridgeService : NotificationListenerService() { val bigText = extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() val lines = extras?.getCharSequenceArray(Notification.EXTRA_TEXT_LINES) + // 汇总通知正文,尽量覆盖 BigText / InboxStyle 等不同样式 val builder = StringBuilder() fun appendValue(value: CharSequence?) { val safe = value?.toString()?.takeIf { it.isNotBlank() } @@ -36,14 +38,33 @@ class NotificationBridgeService : NotificationListenerService() { lines?.forEach { appendValue(it) } val id = sbn.key ?: sbn.id.toString() + val createdAt = isoNow() + val combinedText = if (builder.isNotEmpty()) builder.toString() else text.orEmpty() + val payload = JSONObject() payload.put("id", id) payload.put("channel", title ?: sbn.packageName) - payload.put("text", if (builder.isNotEmpty()) builder.toString() else text.orEmpty()) - payload.put("createdAt", isoNow()) + payload.put("text", combinedText) + payload.put("createdAt", createdAt) payload.put("packageName", sbn.packageName) - // Persist notification in local queue for JS side to fetch via NotificationBridge + // 1) 原生层:直接写入 SQLite(notifications_raw + transactions) + try { + val raw = NotificationDb.RawNotification( + sourceId = id, + packageName = sbn.packageName, + channel = title ?: sbn.packageName, + title = title, + text = combinedText, + postedAt = createdAt + ) + NotificationDb.processAndStoreNotification(applicationContext, raw) + } catch (e: Exception) { + // 防御性处理:保持队列与 JS 通知流程不受影响 + Log.e("NotificationBridgeService", "持久化通知到 SQLite 失败", e) + } + + // 2) 兼容现有 JS 流程:仍然保留 SharedPreferences 队列与广播事件 NotificationStorage.add(applicationContext, payload) // Broadcast an in-app event so the Capacitor plugin can notify JS listeners @@ -62,4 +83,4 @@ class NotificationBridgeService : NotificationListenerService() { formatter.timeZone = TimeZone.getTimeZone("UTC") return formatter.format(Date()) } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt b/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt new file mode 100644 index 0000000..96ae052 --- /dev/null +++ b/android/app/src/main/java/com/echo/app/notification/NotificationDb.kt @@ -0,0 +1,255 @@ +package com.echo.app.notification + +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper +import android.util.Log +import java.util.UUID +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +// 原生 SQLite 访问封装:与 @capacitor-community/sqlite 共用同一个 echo_local 数据库 +object NotificationDb { + + private const val TAG = "NotificationDb" + + // 注意:前端通过 @capacitor-community/sqlite 使用的 DB 名为 "echo_local" + // 插件在 Android 上实际创建的文件名为 "SQLite.db" + private const val DB_FILE_NAME = "echo_localSQLite.db" + private const val DB_VERSION = 1 + + // transactions 表结构需与 src/lib/sqlite.js 保持完全一致 + private const val SQL_CREATE_TRANSACTIONS = """ + CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY NOT NULL, + amount REAL NOT NULL, + merchant TEXT NOT NULL, + category TEXT DEFAULT 'Uncategorized', + date TEXT NOT NULL, + note TEXT, + sync_status INTEGER DEFAULT 0 + ); + """ + + // notifications_raw 表:用于调试与规则命中记录 + private const val SQL_CREATE_NOTIFICATIONS_RAW = """ + CREATE TABLE IF NOT EXISTS notifications_raw ( + id TEXT PRIMARY KEY NOT NULL, + source_id TEXT, + channel TEXT, + title TEXT, + text TEXT, + posted_at TEXT NOT NULL, + status TEXT NOT NULL, + rule_id TEXT, + transaction_id TEXT, + error_message TEXT + ); + """ + + // 简单 SQLiteOpenHelper:主要用于定位与创建数据库文件 + private class EchoDbHelper(context: Context) : + SQLiteOpenHelper(context, DB_FILE_NAME, null, DB_VERSION) { + + override fun onCreate(db: SQLiteDatabase) { + // 正常情况下,数据表由前端首次启动时创建;此处作为兜底 + db.execSQL(SQL_CREATE_TRANSACTIONS) + db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW) + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + // 当前仅有 v1,暂不实现升级逻辑;未来如有版本变更再补充 + } + } + + private var helper: EchoDbHelper? = null + private val lock = ReentrantLock() + + @Volatile + private var schemaInitialized = false + + private fun getHelper(context: Context): EchoDbHelper { + // 使用 applicationContext 避免泄漏 Activity + val appContext = context.applicationContext + return helper ?: synchronized(this) { + helper ?: EchoDbHelper(appContext).also { helper = it } + } + } + + private fun ensureSchema(db: SQLiteDatabase) { + if (schemaInitialized) return + // 多线程下可能执行多次,但都是幂等的 IF NOT EXISTS,性能成本极低 + db.execSQL(SQL_CREATE_TRANSACTIONS) + db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW) + schemaInitialized = true + } + + // 用于规则引擎输入的原始通知模型 + data class RawNotification( + val sourceId: String, + val packageName: String, + val channel: String?, + val title: String?, + val text: String, + val postedAt: String + ) + + /** + * 处理单条通知: + * 1. 写入 notifications_raw,初始 status = 'unmatched' + * 2. 调用 NotificationRuleEngine 做规则解析 + * 3. 命中规则时: + * - 写入一条 transactions 记录 + * - 更新 notifications_raw.status = 'matched',补充 rule_id、transaction_id + * 4. 解析异常时: + * - 更新 notifications_raw.status = 'error',写入 error_message + */ + fun processAndStoreNotification(context: Context, raw: RawNotification) { + lock.withLock { + val db = getHelper(context).writableDatabase + ensureSchema(db) + + val rawId = UUID.randomUUID().toString() + var status = "unmatched" + var ruleId: String? = null + var transactionId: String? = null + var errorMessage: String? = null + + db.beginTransaction() + try { + // 写入 notifications_raw(初始 unmatched) + db.compileStatement( + """ + INSERT INTO notifications_raw ( + id, source_id, channel, title, text, posted_at, status + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).apply { + bindString(1, rawId) + bindString(2, raw.sourceId) + if (raw.channel != null) { + bindString(3, raw.channel) + } else { + bindNull(3) + } + if (raw.title != null) { + bindString(4, raw.title) + } else { + bindNull(4) + } + bindString(5, raw.text) + bindString(6, raw.postedAt) + bindString(7, status) + executeInsert() + close() + } + + // 调用规则引擎 + val parsed = NotificationRuleEngine.parse(raw) + + if (parsed != null) { + ruleId = parsed.ruleId + + // 与 JS 侧逻辑保持一致: + // requiresConfirmation = !(autoCapture && amount!=0 && merchant!='Unknown') + val hasAmount = parsed.amount != 0.0 + val merchantKnown = + parsed.merchant.isNotBlank() && parsed.merchant != "Unknown" + val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown) + + if (!requiresConfirmation) { + // 统一按照设计文档:支出为负数,收入为正数 + val signedAmount = if (parsed.isExpense) { + -kotlin.math.abs(parsed.amount) + } else { + kotlin.math.abs(parsed.amount) + } + transactionId = UUID.randomUUID().toString() + status = "matched" + + db.compileStatement( + """ + INSERT INTO transactions ( + id, amount, merchant, category, date, note, sync_status + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent() + ).apply { + bindString(1, transactionId) + bindDouble(2, signedAmount) + bindString(3, parsed.merchant) + bindString(4, parsed.category) + bindString(5, raw.postedAt) + if (parsed.note != null) { + bindString(6, parsed.note) + } else { + bindNull(6) + } + // sync_status: 0 = pending(本地生成,尚未同步) + bindLong(7, 0L) + executeInsert() + close() + } + } else { + // 保持 status = 'unmatched',但记录命中的 rule_id,方便前端调试视图展示“有推荐规则” + status = "unmatched" + } + } + + // 根据结果更新 notifications_raw 状态 + db.compileStatement( + """ + UPDATE notifications_raw + SET status = ?, rule_id = ?, transaction_id = ?, error_message = ? + WHERE id = ? + """.trimIndent() + ).apply { + bindString(1, status) + if (ruleId != null) { + bindString(2, ruleId) + } else { + bindNull(2) + } + if (transactionId != null) { + bindString(3, transactionId) + } else { + bindNull(3) + } + if (errorMessage != null) { + bindString(4, errorMessage) + } else { + bindNull(4) + } + bindString(5, rawId) + executeUpdateDelete() + close() + } + + db.setTransactionSuccessful() + } catch (e: Exception) { + // 若解析阶段抛异常,尽量在同一事务中回写 error 状态 + Log.e(TAG, "处理通知并写入 SQLite 失败", e) + status = "error" + errorMessage = e.message ?: e.toString() + try { + db.compileStatement( + """ + UPDATE notifications_raw + SET status = ?, error_message = ? + WHERE id = ? + """.trimIndent() + ).apply { + bindString(1, status) + bindString(2, errorMessage!!) + bindString(3, rawId) + executeUpdateDelete() + close() + } + } catch (inner: Exception) { + Log.e(TAG, "更新 notifications_raw 错误状态失败", inner) + } + } finally { + db.endTransaction() + } + } + } +} diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt b/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt new file mode 100644 index 0000000..389a761 --- /dev/null +++ b/android/app/src/main/java/com/echo/app/notification/NotificationRuleEngine.kt @@ -0,0 +1,291 @@ +package com.echo.app.notification + +import kotlin.math.abs + +// 原生通知解析引擎:将 JS 侧的 fallbackRules & 提取逻辑完整搬到 Kotlin, +// 在后台直接把通知转成交易草稿。 +object NotificationRuleEngine { + + // JS 侧规则模型的 Kotlin 版本 + private data class Rule( + val id: String, + val label: String, + val channels: List = emptyList(), + val keywords: List = emptyList(), + val direction: Direction, + val defaultCategory: String, + val autoCapture: Boolean, + val merchantPattern: Regex? = null, + val defaultMerchant: String? = null, + val enabled: Boolean = true, + ) + + private enum class Direction { + INCOME, + EXPENSE, + } + + // 与 src/services/notificationRuleService.js 中的 fallbackRules 一一对应 + private val fallbackRules: List = listOf( + Rule( + id = "alipay-expense", + label = "支付宝消费", + channels = listOf("支付宝", "Alipay"), + keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"), + direction = Direction.EXPENSE, + defaultCategory = "Food", + autoCapture = false, + merchantPattern = Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)""", RegexOption.IGNORE_CASE), + ), + Rule( + id = "alipay-income", + label = "支付宝收款", + channels = listOf("支付宝", "Alipay"), + keywords = listOf("到账", "收入", "收款", "入账"), + direction = Direction.INCOME, + defaultCategory = "Income", + autoCapture = true, + merchantPattern = Regex("""(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)""", RegexOption.IGNORE_CASE), + ), + Rule( + id = "wechat-expense", + label = "微信消费", + channels = listOf("微信支付", "微信", "WeChat"), + keywords = listOf("支付", "支出", "扣款", "消费成功"), + direction = Direction.EXPENSE, + defaultCategory = "Groceries", + autoCapture = false, + merchantPattern = Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""), + ), + Rule( + id = "wechat-income", + label = "微信收款", + channels = listOf("微信支付", "微信", "WeChat"), + keywords = listOf("收款", "到账", "入账", "转入"), + direction = Direction.INCOME, + defaultCategory = "Income", + autoCapture = true, + merchantPattern = Regex("""(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""), + ), + Rule( + id = "bank-income", + label = "工资到账", + channels = listOf( + "招商银行", + "中国银行", + "建设银行", + "工商银行", + "农业银行", + "交通银行", + "浦发银行", + "兴业银行", + "光大银行", + "中信银行", + "广发银行", + "平安银行", + ), + keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"), + direction = Direction.INCOME, + defaultCategory = "Income", + autoCapture = true, + merchantPattern = Regex("""(?:来自|付款方|来源)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""), + ), + Rule( + id = "bank-expense", + label = "银行卡支出", + channels = listOf( + "招商银行", + "中国银行", + "建设银行", + "工商银行", + "农业银行", + "交通银行", + "浦发银行", + "兴业银行", + "光大银行", + "中信银行", + "广发银行", + "平安银行", + "借记卡", + "信用卡", + ), + keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"), + direction = Direction.EXPENSE, + defaultCategory = "Expense", + autoCapture = false, + merchantPattern = Regex("""(?:商户|商家|消费商户)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)"""), + ), + Rule( + id = "transit-card-expense", + label = "公交出行", + channels = listOf( + "杭州通互联互通卡", + "杭州通", + "北京一卡通", + "上海公共交通卡", + "深圳通", + "苏州公交卡", + "交通联合", + "乘车码", + "地铁", + "公交", + "Mi Pay", + "Huawei Pay", + "华为钱包", + "小米钱包", + "OPPO 钱包", + ), + keywords = emptyList(), // 只看来源,不强制正文关键字 + direction = Direction.EXPENSE, + defaultCategory = "Transport", + autoCapture = true, + merchantPattern = Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到)\s*[\u4e00-\u9fa5]{2,})"""), + defaultMerchant = "公交/地铁出行", + ), + ) + + // 解析后的交易结果,与 JS transformNotificationToTransaction 对齐 + data class ParsedTransaction( + val ruleId: String, + val merchant: String, + val category: String, + val amount: Double, + val isExpense: Boolean, + val note: String?, + val autoCapture: Boolean, + ) + + /** + * 尝试从原始通知中解析出一条交易记录。 + * + * 返回 null 表示未命中任何规则或无法解析金额, + * 此时应在前端视图中展示为 unmatched,供用户手动处理。 + */ + fun parse(raw: NotificationDb.RawNotification): ParsedTransaction? { + val content = buildString { + if (!raw.title.isNullOrBlank()) { + append(raw.title.trim()) + append(' ') + } + append(raw.text.trim()) + }.trim() + + if (content.isEmpty()) return null + + val rule = fallbackRules.firstOrNull { matchRule(raw, content, it) } ?: return null + val amount = extractAmount(content) + if (amount <= 0.0) return null + + var merchant = extractMerchant(content, rule) + if ((merchant.isBlank() || merchant == "Unknown") && !rule.defaultMerchant.isNullOrBlank()) { + merchant = rule.defaultMerchant + } + + val isExpense = rule.direction == Direction.EXPENSE + val category = rule.defaultCategory.ifBlank { "Uncategorized" } + + return ParsedTransaction( + ruleId = rule.id, + merchant = merchant.ifBlank { "Unknown" }, + category = category, + amount = amount, + isExpense = isExpense, + note = content.take(200), + autoCapture = rule.autoCapture, + ) + } + + // ===== 内部辅助函数:匹配规则 & 提取金额 / 商户 ===== + + private fun matchRule(raw: NotificationDb.RawNotification, text: String, rule: Rule): Boolean { + if (!rule.enabled) return false + val channel = raw.channel ?: "" + val channelHit = rule.channels.isEmpty() || rule.channels.any { channel.contains(it) } + val keywordHit = rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) } + return channelHit && keywordHit + } + + // 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式 + private fun extractAmount(text: String): Double { + val primaryRegex = + Regex("""(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元""") + val primary = primaryRegex.find(text) + val v1 = primary?.groups?.get(1)?.value + val v2 = primary?.groups?.get(2)?.value + val value = v1 ?: v2 + if (!value.isNullOrBlank()) { + return abs(value.toDoubleOrNull() ?: 0.0) + } + + val looseRegex = Regex("""(-?\d+(?:\.\d{1,2})?)""") + val loose = looseRegex.find(text) + val looseVal = loose?.groups?.get(1)?.value + return if (!looseVal.isNullOrBlank()) abs(looseVal.toDoubleOrNull() ?: 0.0) else 0.0 + } + + // 判断字符串是否更像是「金额」而不是商户名 + private fun looksLikeAmount(raw: String?): Boolean { + val value = raw?.trim().orEmpty() + if (value.isEmpty()) return false + val pattern = Regex("""^[-\d.,]+\s*(?:元|块钱|¥|¥|人民币)?$""") + return pattern.matches(value) + } + + private fun extractMerchant(text: String, rule: Rule): String { + val content = text.ifBlank { "" } + + rule.merchantPattern?.let { regex -> + val m = regex.find(content) + if (m != null && m.groupValues.size > 1) { + val candidate = m.groupValues[1].trim() + if (candidate.isNotEmpty()) return candidate + } + } + + // 通用的「站点 A -> 站点 B」线路模式(公交/地铁通知) + val routeRegex = + Regex("""([\u4e00-\u9fa5]{2,}\s*(?:-|—|>|→|至|到|->)\s*[\u4e00-\u9fa5]{2,})""") + val routeMatch = routeRegex.find(content) + if (routeMatch != null && routeMatch.groupValues.size > 1) { + return routeMatch.groupValues[1].trim() + } + + val genericPatterns = listOf( + Regex("""向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)"""), + Regex("""商户[::]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)"""), + ) + + for (pattern in genericPatterns) { + val m = pattern.find(content) + if (m != null && m.groupValues.size > 1) { + val candidate = m.groupValues[1].trim() + if (candidate.isNotEmpty()) return candidate + } + } + + // 处理「……支出(消费支付宝-上海拉扎斯信息科技有限公司)3.23元」这类银行通知 + val parenMatch = Regex("""\(([^)]+)\)""").find(content) + if (parenMatch != null && parenMatch.groupValues.size > 1) { + val inner = parenMatch.groupValues[1] + val payMatch = Regex("""(?:支付宝|微信支付|微信)[-—\s]*([^\d元¥¥]+)$""").find(inner) + if (payMatch != null && payMatch.groupValues.size > 1) { + val candidate = payMatch.groupValues[1].trim() + if (candidate.isNotEmpty() && !looksLikeAmount(candidate)) { + return candidate + } + } + } + + val parts = content.split(Regex(""":|:""")) + if (parts.size > 1) { + val candidate = + parts[1].split(Regex("""[,,\s]""")).firstOrNull()?.trim().orEmpty() + if (candidate.isNotEmpty() && !looksLikeAmount(candidate)) { + return candidate + } + } + + return rule.defaultMerchant ?: "Unknown" + } +} + diff --git a/src/composables/useTransactionEntry.js b/src/composables/useTransactionEntry.js index 7ba56e5..c672546 100644 --- a/src/composables/useTransactionEntry.js +++ b/src/composables/useTransactionEntry.js @@ -111,15 +111,30 @@ export const useTransactionEntry = () => { continue } - if (!requiresConfirmation) { - await transactionStore.addTransaction(transaction) - await acknowledgeNotification(item.id) + if (nativeBridgeReady) { + // 原生环境:规则在 Kotlin 原生层已经执行并写入 SQLite,这里只负责「待确认」列表和状态同步 + if (!requiresConfirmation) { + // 已由原生自动入账,只需从原生通知队列中移除该条 + await acknowledgeNotification(item.id) + } else { + manualQueue.push({ + ...item, + suggestion: transaction, + ruleId: rule?.id || null, + }) + } } else { - manualQueue.push({ - ...item, - suggestion: transaction, - ruleId: rule?.id || null, - }) + // Web / 模拟环境:沿用原有逻辑,由 JS 侧写入 SQLite + if (!requiresConfirmation) { + await transactionStore.addTransaction(transaction) + await acknowledgeNotification(item.id) + } else { + manualQueue.push({ + ...item, + suggestion: transaction, + ruleId: rule?.id || null, + }) + } } } notifications.value = manualQueue