feat: 添加原生通知桥接功能及相关配置
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -12,6 +12,14 @@ dist
|
|||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.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
|
# Editor directories and files
|
||||||
design/*
|
design/*
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
6
android/app/src/main/assets/capacitor.plugins.json
Normal file
6
android/app/src/main/assets/capacitor.plugins.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor-community/sqlite",
|
||||||
|
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
|
||||||
|
}
|
||||||
|
]
|
||||||
6
android/app/src/main/java/com/echo/app/MainActivity.java
Normal file
6
android/app/src/main/java/com/echo/app/MainActivity.java
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package com.echo.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
}
|
||||||
@@ -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 <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ǰ<EFBFBD>˱<EFBFBD>¶֪ͨȨ<D6AA><EFBFBD><DEBC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ж<EFBFBD>д<EFBFBD><D0B4>??
|
||||||
|
@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) {
|
||||||
|
// <20><><EFBFBD><EFBFBD>ֻ<EFBFBD><D6BB><EFBFBD>ص<EFBFBD>ǰ״̬<D7B4><CCAC><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ת<EFBFBD><D7AA><EFBFBD><EFBFBD>ҳ<EFBFBD><D2B3><EFBFBD><EFBFBD><EFBFBD><EFBFBD> App <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>û<EFBFBD>
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 ֪ͨ<CDA8><D6AA><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ϵͳ֪ͨת<D6AA><D7AA><EFBFBD><EFBFBD> Echo <20><><EFBFBD><EFBFBD><EFBFBD>ѵ<EFBFBD> JSON <20><>д<EFBFBD>뱾<EFBFBD>ض<EFBFBD><D8B6><EFBFBD>
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
// <20>ı<F2B5A5B5><C4B1>س־û<D6BE><C3BB><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ࣺ<EFBFBD><E0A3BA> SharedPreferences <20>洢֪ͨ<CDA8><D6AA><EFBFBD><EFBFBD>
|
||||||
|
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<JSONObject> {
|
||||||
|
val raw = prefs(context).getString(KEY_QUEUE, "[]") ?: "[]"
|
||||||
|
val source = JSONArray(raw)
|
||||||
|
val list = mutableListOf<JSONObject>()
|
||||||
|
for (index in 0 until source.length()) {
|
||||||
|
source.optJSONObject(index)?.let { list.add(it) }
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeQueue(context: Context, queue: List<JSONObject>) {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
13
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
13
android/app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<!-- 背景渐变:参考 icon.html 中的 Warm Gradient -->
|
||||||
|
<size android:width="108dp" android:height="108dp" />
|
||||||
|
<corners android:radius="24dp" />
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:startColor="#FF6B6B"
|
||||||
|
android:endColor="#FFCC70"
|
||||||
|
android:angle="135" />
|
||||||
|
</shape>
|
||||||
|
|
||||||
58
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
58
android/app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<!-- 票据纸张:略带旋转感的卡片 -->
|
||||||
|
<group
|
||||||
|
android:pivotX="54"
|
||||||
|
android:pivotY="54"
|
||||||
|
android:rotation="-6">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M32,24h44c4,0 7,3 7,7v40l-7,-4l-8,5l-8,-5l-8,5l-8,-5l-8,5v-41c0,-4 3,-7 6,-7z" />
|
||||||
|
|
||||||
|
<!-- 标题条 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#4DFF8A65"
|
||||||
|
android:pathData="M36,32h20a2,2 0 0 1 2,2v2a2,2 0 0 1 -2,2h-20a2,2 0 0 1 -2,-2v-2a2,2 0 0 1 2,-2z" />
|
||||||
|
|
||||||
|
<!-- 金额条 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#80FFCC70"
|
||||||
|
android:pathData="M36,42h34a2,2 0 0 1 2,2v2a2,2 0 0 1 -2,2h-34a2,2 0 0 1 -2,-2v-2a2,2 0 0 1 2,-2z" />
|
||||||
|
|
||||||
|
<!-- 中部圆形加号 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FF6B6B"
|
||||||
|
android:strokeWidth="3"
|
||||||
|
android:pathData="M54,60a9,9 0 1,0 0.01,0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FF6B6B"
|
||||||
|
android:strokeWidth="3"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M54,55v10M49,60h10" />
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!-- 右上角轻微信号波纹 -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="4"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeAlpha="0.7"
|
||||||
|
android:pathData="M76,26a16,16 0 0,1 10,14" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="3"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeAlpha="0.3"
|
||||||
|
android:pathData="M80,20a24,24 0 0,1 14,20" />
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
|
||||||
6
android/app/src/main/res/xml/config.xml
Normal file
6
android/app/src/main/res/xml/config.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||||
|
<access origin="*" />
|
||||||
|
|
||||||
|
|
||||||
|
</widget>
|
||||||
9
capacitor.config.ts
Normal file
9
capacitor.config.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'com.echo.app',
|
||||||
|
appName: 'Echo',
|
||||||
|
webDir: 'dist',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -9,12 +9,16 @@ import {
|
|||||||
loadNotificationRules,
|
loadNotificationRules,
|
||||||
transformNotificationToTransaction,
|
transformNotificationToTransaction,
|
||||||
} from '../services/notificationRuleService'
|
} from '../services/notificationRuleService'
|
||||||
|
import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/notificationBridge'
|
||||||
|
|
||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
const processingId = ref('')
|
const processingId = ref('')
|
||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const rulesLoading = ref(false)
|
const rulesLoading = ref(false)
|
||||||
const ruleSet = ref([])
|
const ruleSet = ref([])
|
||||||
|
|
||||||
|
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
||||||
|
let permissionChecked = false
|
||||||
let bootstrapped = false
|
let bootstrapped = false
|
||||||
|
|
||||||
const ensureRulesReady = async () => {
|
const ensureRulesReady = async () => {
|
||||||
@@ -31,6 +35,21 @@ const ensureRulesReady = async () => {
|
|||||||
export const useTransactionEntry = () => {
|
export const useTransactionEntry = () => {
|
||||||
const transactionStore = useTransactionStore()
|
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) => {
|
const removeNotification = (id) => {
|
||||||
notifications.value = notifications.value.filter((item) => item.id !== id)
|
notifications.value = notifications.value.filter((item) => item.id !== id)
|
||||||
}
|
}
|
||||||
@@ -63,6 +82,9 @@ export const useTransactionEntry = () => {
|
|||||||
if (syncing.value) return
|
if (syncing.value) return
|
||||||
syncing.value = true
|
syncing.value = true
|
||||||
try {
|
try {
|
||||||
|
if (nativeBridgeReady) {
|
||||||
|
await ensureNativePermission()
|
||||||
|
}
|
||||||
await ensureRulesReady()
|
await ensureRulesReady()
|
||||||
await transactionStore.ensureInitialized()
|
await transactionStore.ensureInitialized()
|
||||||
const queue = await fetchNotificationQueue()
|
const queue = await fetchNotificationQueue()
|
||||||
|
|||||||
32
src/lib/notificationBridge.js
Normal file
32
src/lib/notificationBridge.js
Normal file
@@ -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
|
||||||
|
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { v4 as uuidv4 } from 'uuid'
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import NotificationBridge, {
|
||||||
|
isNativeNotificationBridgeAvailable,
|
||||||
|
} from '../lib/notificationBridge'
|
||||||
|
|
||||||
|
// 原生插件回调,可按需在应用启动时注入,例如用于上报到服务端
|
||||||
let remoteNotificationFetcher = async () => []
|
let remoteNotificationFetcher = async () => []
|
||||||
let remoteNotificationAcknowledger = async () => {}
|
let remoteNotificationAcknowledger = async () => {}
|
||||||
|
|
||||||
|
// 判断当前是否为原生环境(Android 容器内)
|
||||||
|
const nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地模拟通知缓存,真实环境可以由原生插件 push
|
* 本地模拟通知缓存:
|
||||||
|
* - 浏览器环境:用于 Demo & Mock
|
||||||
|
* - 原生环境:作为 NotificationBridge 数据的补充/兜底
|
||||||
*/
|
*/
|
||||||
let localNotificationQueue = [
|
let localNotificationQueue = [
|
||||||
{
|
{
|
||||||
@@ -38,12 +47,20 @@ export const setRemoteNotificationAcknowledger = (fn) => {
|
|||||||
const sortByCreatedAtDesc = (a, b) =>
|
const sortByCreatedAtDesc = (a, b) =>
|
||||||
new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id)
|
new Date(b.createdAt || b.id) - new Date(a.createdAt || a.id)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一获取待处理通知队列:
|
||||||
|
* - 原生环境:优先从 NotificationBridge 中拿未处理通知
|
||||||
|
* - 浏览器:仅使用本地模拟通知
|
||||||
|
*/
|
||||||
export const fetchNotificationQueue = async () => {
|
export const fetchNotificationQueue = async () => {
|
||||||
const remote = await remoteNotificationFetcher()
|
const remote = await remoteNotificationFetcher()
|
||||||
const composed = [...localNotificationQueue, ...(Array.isArray(remote) ? remote : [])]
|
const composed = [...localNotificationQueue, ...(Array.isArray(remote) ? remote : [])]
|
||||||
return composed.sort(sortByCreatedAtDesc)
|
return composed.sort(sortByCreatedAtDesc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器环境下模拟「新通知」进入队列
|
||||||
|
*/
|
||||||
export const pushLocalNotification = (payload) => {
|
export const pushLocalNotification = (payload) => {
|
||||||
const entry = {
|
const entry = {
|
||||||
id: payload?.id || uuidv4(),
|
id: payload?.id || uuidv4(),
|
||||||
@@ -54,11 +71,42 @@ export const pushLocalNotification = (payload) => {
|
|||||||
localNotificationQueue = [entry, ...localNotificationQueue]
|
localNotificationQueue = [entry, ...localNotificationQueue]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认/忽略某条通知后,从本地队列中移除,并尝试同步到远端
|
||||||
|
*/
|
||||||
export const acknowledgeNotification = async (id) => {
|
export const acknowledgeNotification = async (id) => {
|
||||||
localNotificationQueue = localNotificationQueue.filter((item) => item.id !== id)
|
if (id) {
|
||||||
|
localNotificationQueue = localNotificationQueue.filter((item) => item.id !== id)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await remoteNotificationAcknowledger(id)
|
await remoteNotificationAcknowledger(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[notifications] 远程确认失败,将在下次同步重试', error)
|
console.warn('[notifications] 远程确认失败,将在下次同步重试', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 原生桥接(Android 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user