feat: 添加原生通知监听功能,自动同步通知和交易列表

This commit is contained in:
2025-11-28 10:55:03 +08:00
parent cc2c376e9d
commit a2c5525a2e
8 changed files with 1271 additions and 15 deletions

View File

@@ -17,6 +17,17 @@ android {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
} }
} }
// <20><>ʽͳһ Java / Kotlin <20>ı<EFBFBD><C4B1><EFBFBD>Ŀ<EFBFBD><C4BF><EFBFBD><EFBFBD><E6B1BE><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> Capacitor <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>һ<EFBFBD>£<EFBFBD>Java 21<32><31>
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
kotlinOptions {
jvmTarget = '21'
}
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false

View File

@@ -2,5 +2,9 @@
{ {
"pkg": "@capacitor-community/sqlite", "pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin" "classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
},
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
} }
] ]

View File

@@ -1,17 +1,46 @@
package com.echo.app.notification package com.echo.app.notification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import com.getcapacitor.JSArray import com.getcapacitor.JSArray
import com.getcapacitor.JSObject import com.getcapacitor.JSObject
import com.getcapacitor.Plugin import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall import com.getcapacitor.PluginCall
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.PluginMethod import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
// Capacitor <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ǰ<EFBFBD>˱<EFBFBD>¶֪ͨȨ<D6AA>޼<EFBFBD><DEBC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ж<EFBFBD>д<EFBFBD><EFBFBD>?? // Capacitor <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ǰ<EFBFBD>˱<EFBFBD>¶֪ͨȨ<D6AA>޼<EFBFBD><DEBC><EFBFBD><EFBFBD><EFBFBD>ж<EFBFBD>ȡ<EFBFBD><EFBFBD><EFBFBD>Լ<EFBFBD>֪ͨ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>¼<EFBFBD>
@CapacitorPlugin(name = "NotificationBridge") @CapacitorPlugin(name = "NotificationBridge")
class NotificationBridgePlugin : Plugin() { class NotificationBridgePlugin : Plugin() {
private var notificationPostedReceiver: BroadcastReceiver? = null
override fun load() {
super.load()
// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> NotificationBridgeService <20><>Ӧ<EFBFBD><D3A6><EFBFBD>ڹ㲥<DAB9><E3B2A5><EFBFBD><EFBFBD>ת<EFBFBD><D7AA>Ϊ JS <20>¼<EFBFBD>
val filter = IntentFilter("com.echo.app.NOTIFICATION_POSTED")
notificationPostedReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val payload = JSObject()
payload.put("id", intent.getStringExtra("id"))
notifyListeners("notificationPosted", payload)
}
}
context.registerReceiver(notificationPostedReceiver, filter)
}
override fun handleOnDestroy() {
notificationPostedReceiver?.let { receiver ->
context.unregisterReceiver(receiver)
}
notificationPostedReceiver = null
super.handleOnDestroy()
}
@PluginMethod @PluginMethod
fun hasPermission(call: PluginCall) { fun hasPermission(call: PluginCall) {
val enabled = NotificationManagerCompat.getEnabledListenerPackages(context) val enabled = NotificationManagerCompat.getEnabledListenerPackages(context)
@@ -23,7 +52,7 @@ class NotificationBridgePlugin : Plugin() {
@PluginMethod @PluginMethod
fun requestPermission(call: PluginCall) { fun requestPermission(call: PluginCall) {
// <20><><EFBFBD><EFBFBD>ֻ<EFBFBD><EFBFBD><EFBFBD>ص<EFBFBD>ǰ״̬<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ת<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ҳ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> App <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>û<EFBFBD> // <20><>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>״̬<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>û<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ǰ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>ת<EFBFBD><EFBFBD>ϵͳ<EFBFBD><EFBFBD><EFBFBD><EFBFBD>
hasPermission(call) hasPermission(call)
} }

View File

@@ -1,6 +1,7 @@
package com.echo.app.notification package com.echo.app.notification
import android.app.Notification import android.app.Notification
import android.content.Intent
import android.service.notification.NotificationListenerService import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -9,7 +10,7 @@ import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import org.json.JSONObject import org.json.JSONObject
// Android ֪ͨ<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>񣺰<EFBFBD>ϵͳ֪ͨת<EFBFBD><EFBFBD><EFBFBD><EFBFBD> Echo <20><><EFBFBD><EFBFBD><EFBFBD>ѵ<EFBFBD> JSON <20><>д<EFBFBD><EFBFBD>ض<EFBFBD><D8B6><EFBFBD> // Android notification listener: persist notifications to local storage and notify app via broadcast
class NotificationBridgeService : NotificationListenerService() { class NotificationBridgeService : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
val notification = sbn.notification ?: return val notification = sbn.notification ?: return
@@ -22,11 +23,11 @@ class NotificationBridgeService : NotificationListenerService() {
val builder = StringBuilder() val builder = StringBuilder()
fun appendValue(value: CharSequence?) { fun appendValue(value: CharSequence?) {
val safe = value?.toString()?.takeIf { it.isNotBlank() } val safe = value?.toString()?.takeIf { it.isNotBlank() }
if (!safe.isNullOrEmpty()) { if (!safe.isNullOrEmpty()) {
if (builder.isNotEmpty()) builder.append(' ') if (builder.isNotEmpty()) builder.append(' ')
builder.append(safe) builder.append(safe)
} }
} }
appendValue(text) appendValue(text)
@@ -34,14 +35,22 @@ class NotificationBridgeService : NotificationListenerService() {
appendValue(subText) appendValue(subText)
lines?.forEach { appendValue(it) } lines?.forEach { appendValue(it) }
val id = sbn.key ?: sbn.id.toString()
val payload = JSONObject() val payload = JSONObject()
payload.put("id", sbn.key ?: sbn.id.toString()) payload.put("id", id)
payload.put("channel", title ?: sbn.packageName) payload.put("channel", title ?: sbn.packageName)
payload.put("text", if (builder.isNotEmpty()) builder.toString() else text.orEmpty()) payload.put("text", if (builder.isNotEmpty()) builder.toString() else text.orEmpty())
payload.put("createdAt", isoNow()) payload.put("createdAt", isoNow())
payload.put("packageName", sbn.packageName) payload.put("packageName", sbn.packageName)
// Persist notification in local queue for JS side to fetch via NotificationBridge
NotificationStorage.add(applicationContext, payload) NotificationStorage.add(applicationContext, payload)
// Broadcast an in-app event so the Capacitor plugin can notify JS listeners
val intent = Intent("com.echo.app.NOTIFICATION_POSTED")
intent.setPackage(packageName)
intent.putExtra("id", id)
sendBroadcast(intent)
} }
override fun onNotificationRemoved(sbn: StatusBarNotification) { override fun onNotificationRemoved(sbn: StatusBarNotification) {

1161
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@
}, },
"dependencies": { "dependencies": {
"@capacitor-community/sqlite": "^5.7.2", "@capacitor-community/sqlite": "^5.7.2",
"@capacitor/android": "^5.7.8",
"@capacitor/app": "^5.0.0",
"@capacitor/core": "^5.7.8", "@capacitor/core": "^5.7.8",
"@phosphor-icons/web": "^2.1.2", "@phosphor-icons/web": "^2.1.2",
"jeep-sqlite": "^2.7.1", "jeep-sqlite": "^2.7.1",
@@ -21,10 +23,12 @@
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@capacitor/cli": "^7.4.4",
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.17",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.9.3",
"vite": "^7.2.4" "vite": "^7.2.4"
} }
} }

View File

@@ -20,6 +20,7 @@ const ruleSet = ref([])
const nativeBridgeReady = isNativeNotificationBridgeAvailable() const nativeBridgeReady = isNativeNotificationBridgeAvailable()
let permissionChecked = false let permissionChecked = false
let bootstrapped = false let bootstrapped = false
let nativeNotificationListener = null
const ensureRulesReady = async () => { const ensureRulesReady = async () => {
if (ruleSet.value.length) return ruleSet.value if (ruleSet.value.length) return ruleSet.value
@@ -119,6 +120,19 @@ export const useTransactionEntry = () => {
if (!bootstrapped) { if (!bootstrapped) {
bootstrapped = true bootstrapped = true
syncNotifications() syncNotifications()
// 原生环境:监听 NotificationBridgePlugin 推送的 notificationPosted 事件,做到“通知一到就同步一次”
if (nativeBridgeReady && !nativeNotificationListener && typeof NotificationBridge?.addListener === 'function') {
NotificationBridge.addListener('notificationPosted', async () => {
await syncNotifications()
})
.then((handle) => {
nativeNotificationListener = handle
})
.catch((error) => {
console.warn('[notifications] 无法注册 notificationPosted 监听', error)
})
}
} }
return { return {

View File

@@ -1,15 +1,39 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { App } from '@capacitor/app'
import { useTransactionStore } from '../stores/transactions' import { useTransactionStore } from '../stores/transactions'
import { useTransactionEntry } from '../composables/useTransactionEntry' import { useTransactionEntry } from '../composables/useTransactionEntry'
const transactionStore = useTransactionStore() const transactionStore = useTransactionStore()
const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } = const { totalIncome, totalExpense, todaysIncome, todaysExpense, latestTransactions } =
storeToRefs(transactionStore) storeToRefs(transactionStore)
const { notifications, confirmNotification, dismissNotification, processingId } = const { notifications, confirmNotification, dismissNotification, processingId, syncNotifications } =
useTransactionEntry() useTransactionEntry()
let appStateListener = null
onMounted(async () => {
try {
// App 从后台回到前台时,自动同步一次通知和交易列表
appStateListener = await App.addListener('appStateChange', async ({ isActive }) => {
if (isActive) {
await syncNotifications()
}
})
} catch (error) {
// Web 环境或插件不可用时忽略
console.warn('[notifications] appStateChange listener failed', error)
}
})
onBeforeUnmount(() => {
if (appStateListener && typeof appStateListener.remove === 'function') {
appStateListener.remove()
}
appStateListener = null
})
const emit = defineEmits(['changeTab']) const emit = defineEmits(['changeTab'])
const monthlyBudget = 12000 const monthlyBudget = 12000