feat: 实现原生通知持久化与解析,支持 SQLite 存储和规则引擎

This commit is contained in:
2025-12-03 17:34:22 +08:00
parent 67880c1bba
commit 1dd9d22abd
4 changed files with 595 additions and 13 deletions

View File

@@ -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) 原生层:直接写入 SQLitenotifications_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())
}
}
}

View File

@@ -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 上实际创建的文件名为 "<dbName>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()
}
}
}
}

View File

@@ -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<String> = emptyList(),
val keywords: List<String> = 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<Rule> = 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"
}
}

View File

@@ -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