feat: 添加原生通知桥接功能及相关配置

This commit is contained in:
2025-11-27 13:50:21 +08:00
parent 074a7f1ff0
commit 7cba2965a9
15 changed files with 401 additions and 2 deletions

View File

@@ -0,0 +1,6 @@
[
{
"pkg": "@capacitor-community/sqlite",
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
}
]

View File

@@ -0,0 +1,6 @@
package com.echo.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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())
}
}
}

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

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

View File

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

View File

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

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