From 7cba2965a92518bfdc5409582363952a70aa5c51 Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Thu, 27 Nov 2025 13:50:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E6=A1=A5=E6=8E=A5=E5=8A=9F=E8=83=BD=E5=8F=8A?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +++ .../src/main/assets/capacitor.plugins.json | 6 ++ .../main/java/com/echo/app/MainActivity.java | 6 ++ .../notification/NotificationBridgePlugin.kt | 54 +++++++++++++++ .../notification/NotificationBridgeService.kt | 56 +++++++++++++++ .../app/notification/NotificationStorage.kt | 69 +++++++++++++++++++ .../res/drawable/ic_launcher_background.xml | 13 ++++ .../res/drawable/ic_launcher_foreground.xml | 58 ++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 ++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 ++ android/app/src/main/res/xml/config.xml | 6 ++ capacitor.config.ts | 9 +++ src/composables/useTransactionEntry.js | 22 ++++++ src/lib/notificationBridge.js | 32 +++++++++ src/services/notificationSourceService.js | 52 +++++++++++++- 15 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/assets/capacitor.plugins.json create mode 100644 android/app/src/main/java/com/echo/app/MainActivity.java create mode 100644 android/app/src/main/java/com/echo/app/notification/NotificationBridgePlugin.kt create mode 100644 android/app/src/main/java/com/echo/app/notification/NotificationBridgeService.kt create mode 100644 android/app/src/main/java/com/echo/app/notification/NotificationStorage.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/xml/config.xml create mode 100644 capacitor.config.ts create mode 100644 src/lib/notificationBridge.js diff --git a/.gitignore b/.gitignore index 3f0de42..2463ace 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,14 @@ dist dist-ssr *.local +# Capacitor / Android generated files +android/app/capacitor.build.gradle +android/capacitor.settings.gradle +android/capacitor-cordova-android-plugins/** +android/app/src/main/assets/public/** +android/app/src/main/assets/capacitor.config.json +capacitor.config.json + # Editor directories and files design/* .vscode/* diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json new file mode 100644 index 0000000..6980007 --- /dev/null +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -0,0 +1,6 @@ +[ + { + "pkg": "@capacitor-community/sqlite", + "classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin" + } +] diff --git a/android/app/src/main/java/com/echo/app/MainActivity.java b/android/app/src/main/java/com/echo/app/MainActivity.java new file mode 100644 index 0000000..e46deba --- /dev/null +++ b/android/app/src/main/java/com/echo/app/MainActivity.java @@ -0,0 +1,6 @@ +package com.echo.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { +} diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationBridgePlugin.kt b/android/app/src/main/java/com/echo/app/notification/NotificationBridgePlugin.kt new file mode 100644 index 0000000..08ab27f --- /dev/null +++ b/android/app/src/main/java/com/echo/app/notification/NotificationBridgePlugin.kt @@ -0,0 +1,54 @@ +package com.echo.app.notification + +import androidx.core.app.NotificationManagerCompat +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.annotation.CapacitorPlugin +import com.getcapacitor.PluginMethod + +// Capacitor 插件:给前端暴露通知权限检查与队列读写能?? +@CapacitorPlugin(name = "NotificationBridge") +class NotificationBridgePlugin : Plugin() { + + @PluginMethod + fun hasPermission(call: PluginCall) { + val enabled = NotificationManagerCompat.getEnabledListenerPackages(context) + val granted = enabled.contains(context.packageName) + val payload = JSObject() + payload.put("granted", granted) + call.resolve(payload) + } + + @PluginMethod + fun requestPermission(call: PluginCall) { + // 这里只返回当前状态,真正跳转设置页建议在 App 内引导用户 + hasPermission(call) + } + + @PluginMethod + fun getPendingNotifications(call: PluginCall) { + val queue = NotificationStorage.list(context) + val payload = JSObject() + payload.put("notifications", JSArray(queue.toString())) + call.resolve(payload) + } + + @PluginMethod + fun acknowledgeNotification(call: PluginCall) { + val id = call.getString("id") + if (id.isNullOrBlank()) { + call.reject("id is required") + return + } + NotificationStorage.remove(context, id) + call.resolve() + } + + @PluginMethod + fun clearNotifications(call: PluginCall) { + NotificationStorage.clear(context) + call.resolve() + } +} 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 new file mode 100644 index 0000000..da25715 --- /dev/null +++ b/android/app/src/main/java/com/echo/app/notification/NotificationBridgeService.kt @@ -0,0 +1,56 @@ +package com.echo.app.notification + +import android.app.Notification +import android.service.notification.NotificationListenerService +import android.service.notification.StatusBarNotification +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import org.json.JSONObject + +// Android 通知监听服务:把系统通知转换成 Echo 可消费的 JSON 并写入本地队列 +class NotificationBridgeService : NotificationListenerService() { + override fun onNotificationPosted(sbn: StatusBarNotification) { + val notification = sbn.notification ?: return + val extras = notification.extras + val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString() + val text = extras?.getCharSequence(Notification.EXTRA_TEXT)?.toString() + val subText = extras?.getCharSequence(Notification.EXTRA_SUB_TEXT)?.toString() + val bigText = extras?.getCharSequence(Notification.EXTRA_BIG_TEXT)?.toString() + val lines = extras?.getCharSequenceArray(Notification.EXTRA_TEXT_LINES) + + val builder = StringBuilder() + fun appendValue(value: CharSequence?) { + val safe = value?.toString()?.takeIf { it.isNotBlank() } + if (!safe.isNullOrEmpty()) { + if (builder.isNotEmpty()) builder.append(' ') + builder.append(safe) + } + } + + appendValue(text) + if (bigText != text) appendValue(bigText) + appendValue(subText) + lines?.forEach { appendValue(it) } + + val payload = JSONObject() + payload.put("id", sbn.key ?: sbn.id.toString()) + payload.put("channel", title ?: sbn.packageName) + payload.put("text", if (builder.isNotEmpty()) builder.toString() else text.orEmpty()) + payload.put("createdAt", isoNow()) + payload.put("packageName", sbn.packageName) + + NotificationStorage.add(applicationContext, payload) + } + + override fun onNotificationRemoved(sbn: StatusBarNotification) { + NotificationStorage.remove(applicationContext, sbn.key ?: sbn.id.toString()) + } + + private fun isoNow(): String { + val formatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + formatter.timeZone = TimeZone.getTimeZone("UTC") + return formatter.format(Date()) + } +} diff --git a/android/app/src/main/java/com/echo/app/notification/NotificationStorage.kt b/android/app/src/main/java/com/echo/app/notification/NotificationStorage.kt new file mode 100644 index 0000000..e673ec6 --- /dev/null +++ b/android/app/src/main/java/com/echo/app/notification/NotificationStorage.kt @@ -0,0 +1,69 @@ +package com.echo.app.notification + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +// 简单的本地持久化帮助类:用 SharedPreferences 存储通知队列 +object NotificationStorage { + private const val PREF_NAME = "echo_notifications" + private const val KEY_QUEUE = "queue" + private const val MAX_ITEMS = 32 + private val lock = ReentrantLock() + + private fun prefs(context: Context) = + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + + private fun readQueue(context: Context): MutableList { + val raw = prefs(context).getString(KEY_QUEUE, "[]") ?: "[]" + val source = JSONArray(raw) + val list = mutableListOf() + for (index in 0 until source.length()) { + source.optJSONObject(index)?.let { list.add(it) } + } + return list + } + + private fun writeQueue(context: Context, queue: List) { + val array = JSONArray() + queue.forEach { array.put(it) } + prefs(context).edit().putString(KEY_QUEUE, array.toString()).apply() + } + + fun add(context: Context, payload: JSONObject) { + lock.withLock { + val queue = readQueue(context) + queue.removeAll { it.optString("id") == payload.optString("id") } + queue.add(0, payload) + while (queue.size > MAX_ITEMS) { + queue.removeAt(queue.lastIndex) + } + writeQueue(context, queue) + } + } + + fun remove(context: Context, id: String) { + lock.withLock { + val queue = readQueue(context) + queue.removeAll { it.optString("id") == id } + writeQueue(context, queue) + } + } + + fun list(context: Context): JSONArray { + lock.withLock { + val queue = readQueue(context) + val array = JSONArray() + queue.forEach { array.put(it) } + return array + } + } + + fun clear(context: Context) { + lock.withLock { + writeQueue(context, emptyList()) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..8608e78 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..3f2b7a8 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2a6fee8 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..2a6fee8 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/xml/config.xml b/android/app/src/main/res/xml/config.xml new file mode 100644 index 0000000..1b1b0e0 --- /dev/null +++ b/android/app/src/main/res/xml/config.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/capacitor.config.ts b/capacitor.config.ts new file mode 100644 index 0000000..79fde8f --- /dev/null +++ b/capacitor.config.ts @@ -0,0 +1,9 @@ +import type { CapacitorConfig } from '@capacitor/cli'; + +const config: CapacitorConfig = { + appId: 'com.echo.app', + appName: 'Echo', + webDir: 'dist', +}; + +export default config; diff --git a/src/composables/useTransactionEntry.js b/src/composables/useTransactionEntry.js index 7afdf97..4f4adeb 100644 --- a/src/composables/useTransactionEntry.js +++ b/src/composables/useTransactionEntry.js @@ -9,12 +9,16 @@ import { loadNotificationRules, transformNotificationToTransaction, } from '../services/notificationRuleService' +import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge' const notifications = ref([]) const processingId = ref('') const syncing = ref(false) const rulesLoading = ref(false) const ruleSet = ref([]) + +const nativeBridgeReady = isNativeNotificationBridgeAvailable() +let permissionChecked = false let bootstrapped = false const ensureRulesReady = async () => { @@ -31,6 +35,21 @@ const ensureRulesReady = async () => { export const useTransactionEntry = () => { const transactionStore = useTransactionStore() + // 鍘熺敓绔細鍦ㄩ娆″悓姝ュ墠鍋氫竴娆℃潈闄愭牎楠屼笌璇锋眰 + const ensureNativePermission = async () => { + if (!nativeBridgeReady || permissionChecked) return + try { + const result = await NotificationBridge.hasPermission() + if (!result?.granted) { + await NotificationBridge.requestPermission() + } + } catch (error) { + console.warn('[notifications] 鍘熺敓鏉冮檺妫鏌ュけ璐', error) + } finally { + permissionChecked = true + } + } + const removeNotification = (id) => { notifications.value = notifications.value.filter((item) => item.id !== id) } @@ -63,6 +82,9 @@ export const useTransactionEntry = () => { if (syncing.value) return syncing.value = true try { + if (nativeBridgeReady) { + await ensureNativePermission() + } await ensureRulesReady() await transactionStore.ensureInitialized() const queue = await fetchNotificationQueue() diff --git a/src/lib/notificationBridge.js b/src/lib/notificationBridge.js new file mode 100644 index 0000000..e5d0c75 --- /dev/null +++ b/src/lib/notificationBridge.js @@ -0,0 +1,32 @@ +import { Capacitor, registerPlugin } from '@capacitor/core' + +// Web 绔殑鍏滃簳瀹炵幇锛氫繚璇佹祻瑙堝櫒鐜涓嬩笉浼氭姤閿 +const webFallback = { + async hasPermission() { + return { granted: true } + }, + async requestPermission() { + return { granted: true } + }, + async getPendingNotifications() { + return { notifications: [] } + }, + async acknowledgeNotification() { + return { acknowledged: true } + }, + async clearNotifications() { + return { cleared: true } + }, +} + +const NotificationBridge = registerPlugin('NotificationBridge', { + web: () => webFallback, +}) + +export const isNativeNotificationBridgeAvailable = () => + typeof Capacitor.isNativePlatform === 'function' + ? Capacitor.isNativePlatform() + : Capacitor.getPlatform() !== 'web' + +export default NotificationBridge + diff --git a/src/services/notificationSourceService.js b/src/services/notificationSourceService.js index 2fb7137..9b2e4eb 100644 --- a/src/services/notificationSourceService.js +++ b/src/services/notificationSourceService.js @@ -1,10 +1,19 @@ import { v4 as uuidv4 } from 'uuid' +import NotificationBridge, { + isNativeNotificationBridgeAvailable, +} from '../lib/notificationBridge' +// 鍘熺敓鎻掍欢鍥炶皟锛屽彲鎸夐渶鍦ㄥ簲鐢ㄥ惎鍔ㄦ椂娉ㄥ叆锛屼緥濡傜敤浜庝笂鎶ュ埌鏈嶅姟绔 let remoteNotificationFetcher = async () => [] let remoteNotificationAcknowledger = async () => {} +// 鍒ゆ柇褰撳墠鏄惁涓哄師鐢熺幆澧冿紙Android 瀹瑰櫒鍐咃級 +const nativeBridgeReady = isNativeNotificationBridgeAvailable() + /** - * 鏈湴妯℃嫙閫氱煡缂撳瓨锛岀湡瀹炵幆澧冨彲浠ョ敱鍘熺敓鎻掍欢 push + * 鏈湴妯℃嫙閫氱煡缂撳瓨锛 + * - 娴忚鍣ㄧ幆澧冿細鐢ㄤ簬 Demo & Mock + * - 鍘熺敓鐜锛氫綔涓 NotificationBridge 鏁版嵁鐨勮ˉ鍏/鍏滃簳 */ let localNotificationQueue = [ { @@ -38,12 +47,20 @@ export const setRemoteNotificationAcknowledger = (fn) => { const sortByCreatedAtDesc = (a, b) => new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id) +/** + * 缁熶竴鑾峰彇寰呭鐞嗛氱煡闃熷垪锛 + * - 鍘熺敓鐜锛氫紭鍏堜粠 NotificationBridge 涓嬁鏈鐞嗛氱煡 + * - 娴忚鍣細浠呬娇鐢ㄦ湰鍦版ā鎷熼氱煡 + */ export const fetchNotificationQueue = async () => { const remote = await remoteNotificationFetcher() const composed = [...localNotificationQueue, ...(Array.isArray(remote) ? remote : [])] return composed.sort(sortByCreatedAtDesc) } +/** + * 娴忚鍣ㄧ幆澧冧笅妯℃嫙銆屾柊閫氱煡銆嶈繘鍏ラ槦鍒 + */ export const pushLocalNotification = (payload) => { const entry = { id: payload?.id || uuidv4(), @@ -54,11 +71,42 @@ export const pushLocalNotification = (payload) => { localNotificationQueue = [entry, ...localNotificationQueue] } +/** + * 纭/蹇界暐鏌愭潯閫氱煡鍚庯紝浠庢湰鍦伴槦鍒椾腑绉婚櫎锛屽苟灏濊瘯鍚屾鍒拌繙绔 + */ export const acknowledgeNotification = async (id) => { - localNotificationQueue = localNotificationQueue.filter((item) => item.id !== id) + if (id) { + localNotificationQueue = localNotificationQueue.filter((item) => item.id !== id) + } try { await remoteNotificationAcknowledger(id) } catch (error) { console.warn('[notifications] 杩滅▼纭澶辫触锛屽皢鍦ㄤ笅娆″悓姝ラ噸璇', error) } } + +// ===== 鍘熺敓妗ユ帴锛圓ndroid NotificationListenerService锛夐泦鎴 ===== + +if (nativeBridgeReady) { + // 浠庡師鐢熷眰鎷夊彇寰呭鐞嗛氱煡 + setRemoteNotificationFetcher(async () => { + try { + const { notifications = [] } = await NotificationBridge.getPendingNotifications() + return notifications + } catch (error) { + console.warn('[notifications] 鑾峰彇鍘熺敓閫氱煡澶辫触锛岄鍥炴湰鍦版ā鎷熸暟鎹', error) + return [] + } + }) + + // 閫氱煡宸茬‘璁/蹇界暐鍚庯紝鍛婄煡鍘熺敓灞傛竻闄ゅ搴旇褰 + setRemoteNotificationAcknowledger(async (id) => { + if (!id) return + try { + await NotificationBridge.acknowledgeNotification({ id }) + } catch (error) { + console.warn('[notifications] 閫氱煡纭鍚屾澶辫触', error) + } + }) +} +