|
@@ -0,0 +1,200 @@
|
|
|
|
|
+package cn.fmode.app;
|
|
|
|
|
+
|
|
|
|
|
+import android.app.Notification;
|
|
|
|
|
+import android.app.NotificationChannel;
|
|
|
|
|
+import android.app.NotificationManager;
|
|
|
|
|
+import android.app.PendingIntent;
|
|
|
|
|
+import android.content.Context;
|
|
|
|
|
+import android.content.Intent;
|
|
|
|
|
+import android.os.Build;
|
|
|
|
|
+
|
|
|
|
|
+import com.getcapacitor.JSObject;
|
|
|
|
|
+import com.getcapacitor.Plugin;
|
|
|
|
|
+import com.getcapacitor.PluginCall;
|
|
|
|
|
+import com.getcapacitor.PluginMethod;
|
|
|
|
|
+import com.getcapacitor.annotation.CapacitorPlugin;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 发系统通知(2026-11-15)
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 为什么不用 @capacitor/local-notifications
|
|
|
|
|
+ *
|
|
|
|
|
+ * 该插件在本项目真机上暴露了三个问题,且都**无法从 JS 侧绕过**:
|
|
|
|
|
+ *
|
|
|
|
|
+ * 1. `isExactNotification` 默认 true → 强制跳「闹钟和提醒」设置页
|
|
|
|
|
+ * (虽然可以传 false 规避,但说明默认值反直觉)
|
|
|
|
|
+ * 2. 渠道不创建就静默丢通知 —— 已用 createChannel 解决
|
|
|
|
|
+ * 3. 🔴 **点击通知拉不起 App**(本轮实测):插件用
|
|
|
|
|
+ * `PendingIntent.getActivity(context, id, intent, FLAG_CANCEL_CURRENT | FLAG_MUTABLE)`
|
|
|
|
|
+ * 构造点击意图。在 Android 12+ 上 MUTABLE + 某些 Intent 组合会静默失效,
|
|
|
|
|
+ * 表现为「通知在、点没反应」。adb `am start` 走同一套 Intent 却正常,
|
|
|
|
|
+ * 说明问题只在 PendingIntent 这一层 —— JS 侧完全无法干预。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 我们只需要「发一条即时通知 + 点击打开 App 并跳到指定页面」这一件事,
|
|
|
|
|
+ * 自建实现更可控:渠道、图标、PendingIntent 全部由我们决定。
|
|
|
|
|
+ *
|
|
|
|
|
+ * ## 点击后怎么跳
|
|
|
|
|
+ *
|
|
|
|
|
+ * PendingIntent 指向 MainActivity,并带上 `fmode://` 深链(extra: deepLink)。
|
|
|
|
|
+ * MainActivity 已在用 Capacitor 默认的 singleTask + `appUrlOpen` 流程;
|
|
|
|
|
+ * JS 侧收到后由 `DeepLinkService` 解析跳转。这里**额外**给 Intent 塞
|
|
|
|
|
+ * `intent.data = Uri.parse(deepLink)`,这样即使 App 被冷启动,
|
|
|
|
|
+ * Capacitor 也能通过 launchUrl 拿到目标。
|
|
|
|
|
+ */
|
|
|
|
|
+@CapacitorPlugin(name = "FmodeNotify")
|
|
|
|
|
+public class FmodeNotifyPlugin extends Plugin {
|
|
|
|
|
+
|
|
|
|
|
+ private static final String CHANNEL_ID = "fmode-default";
|
|
|
|
|
+
|
|
|
|
|
+ @PluginMethod
|
|
|
|
|
+ public void show(PluginCall call) {
|
|
|
|
|
+ String title = call.getString("title", "");
|
|
|
|
|
+ String body = call.getString("body", "");
|
|
|
|
|
+ String deepLink = call.getString("deepLink");
|
|
|
|
|
+ Integer id = call.getInt("id", (int) (System.currentTimeMillis() % 100000));
|
|
|
|
|
+
|
|
|
|
|
+ Context ctx = getContext();
|
|
|
|
|
+ NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
|
|
|
|
|
+ if (nm == null) {
|
|
|
|
|
+ call.reject("NotificationManager 不可用");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ensureChannel(nm);
|
|
|
|
|
+
|
|
|
|
|
+ // 点击意图:打开 MainActivity,并携带深链
|
|
|
|
|
+ Intent intent = new Intent(ctx, MainActivity.class);
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 只用 MAIN + LAUNCHER,**不要设 ACTION_VIEW 与 data**(2026-11-15 实测)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 踩坑过程:最初写成
|
|
|
|
|
+ * intent.setAction(Intent.ACTION_VIEW);
|
|
|
|
|
+ * intent.setData(Uri.parse("fmode://contacts"));
|
|
|
|
|
+ * 结果点通知后**通知消失了、App 却没起来** ——
|
|
|
|
|
+ * 因为 ACTION_VIEW + 自定义 scheme 会进入「外部链接解析」流程,
|
|
|
|
|
+ * 系统拿它去匹配 intent-filter,而不是直接启动我们指定的 ComponentName,
|
|
|
|
|
+ * 命中失败就静默丢弃。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 正确做法:显式 ComponentName(new Intent(ctx, MainActivity.class))
|
|
|
|
|
+ * 本身就是最可靠的启动方式,深链只通过 extra 传递,
|
|
|
|
|
+ * 由 JS 侧(DeepLinkService)解析后跳页。
|
|
|
|
|
+ */
|
|
|
|
|
+ intent.setAction(Intent.ACTION_MAIN);
|
|
|
|
|
+ intent.addCategory(Intent.CATEGORY_LAUNCHER);
|
|
|
|
|
+ intent.setFlags(
|
|
|
|
|
+ Intent.FLAG_ACTIVITY_NEW_TASK
|
|
|
|
|
+ | Intent.FLAG_ACTIVITY_SINGLE_TOP
|
|
|
|
|
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
|
|
|
+ );
|
|
|
|
|
+ if (deepLink != null && !deepLink.isEmpty()) {
|
|
|
|
|
+ intent.putExtra("deepLink", deepLink);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /*
|
|
|
|
|
+ * 🔴 flags 必须同时含 IMMUTABLE —— Android 12+ 强制要求显式指定可变性,
|
|
|
|
|
+ * 不指定会抛异常;而 MUTABLE 在部分 ROM 上会让点击静默失效(插件即栽在这)。
|
|
|
|
|
+ * IMMUTABLE 足够:我们不在 PendingIntent 发出后修改它的内容。
|
|
|
|
|
+ */
|
|
|
|
|
+ int piFlags = PendingIntent.FLAG_UPDATE_CURRENT;
|
|
|
|
|
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
|
|
|
+ piFlags |= PendingIntent.FLAG_IMMUTABLE;
|
|
|
|
|
+ }
|
|
|
|
|
+ PendingIntent pi = PendingIntent.getActivity(ctx, id, intent, piFlags);
|
|
|
|
|
+
|
|
|
|
|
+ Notification.Builder builder;
|
|
|
|
|
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
|
|
|
+ builder = new Notification.Builder(ctx, CHANNEL_ID);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ builder = new Notification.Builder(ctx);
|
|
|
|
|
+ builder.setPriority(Notification.PRIORITY_HIGH);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ builder
|
|
|
|
|
+ .setContentTitle(title)
|
|
|
|
|
+ .setContentText(body)
|
|
|
|
|
+ .setStyle(new Notification.BigTextStyle().bigText(body))
|
|
|
|
|
+ .setSmallIcon(ctx.getApplicationInfo().icon)
|
|
|
|
|
+ .setContentIntent(pi)
|
|
|
|
|
+ .setAutoCancel(true);
|
|
|
|
|
+
|
|
|
|
|
+ // 首条消息可以用 heads-up:优先级高才会弹横幅
|
|
|
|
|
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
|
|
|
+ builder.setChannelId(CHANNEL_ID);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ nm.notify(id, builder.build());
|
|
|
|
|
+
|
|
|
|
|
+ JSObject ret = new JSObject();
|
|
|
|
|
+ ret.put("id", id);
|
|
|
|
|
+ call.resolve(ret);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @PluginMethod
|
|
|
|
|
+ public void cancel(PluginCall call) {
|
|
|
|
|
+ Integer id = call.getInt("id");
|
|
|
|
|
+ if (id == null) {
|
|
|
|
|
+ call.reject("缺少 id");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ NotificationManager nm = (NotificationManager) getContext()
|
|
|
|
|
+ .getSystemService(Context.NOTIFICATION_SERVICE);
|
|
|
|
|
+ if (nm != null) nm.cancel(id);
|
|
|
|
|
+ call.resolve();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void ensureChannel(NotificationManager nm) {
|
|
|
|
|
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return;
|
|
|
|
|
+ NotificationChannel ch = new NotificationChannel(
|
|
|
|
|
+ CHANNEL_ID, "消息与任务", NotificationManager.IMPORTANCE_HIGH);
|
|
|
|
|
+ ch.setDescription("容器任务完成与新消息提醒");
|
|
|
|
|
+ ch.enableVibration(true);
|
|
|
|
|
+ ch.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE);
|
|
|
|
|
+ nm.createNotificationChannel(ch);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== 深链传递(2026-11-15) ====================
|
|
|
|
|
+ //
|
|
|
|
|
+ // 点击通知时 Intent 带的是 extra `deepLink`(不是 data,原因见 show() 的注释)。
|
|
|
|
|
+ // Capacitor 的 appUrlOpen 只认 data,所以这里自己把深链交给 JS:
|
|
|
|
|
+ // · 冷启动 → JS 起来后主动调 getPendingDeepLink() 取一次
|
|
|
|
|
+ // · 热启动 → Activity.onNewIntent 调 pushDeepLink() 推给 JS
|
|
|
|
|
+ //
|
|
|
|
|
+ // 🔴 用静态字段持有待处理深链,因为 Activity 与 Plugin 生命周期不同步:
|
|
|
|
|
+ // 冷启动时 Intent 可能早于 Plugin 创建,直接抛事件会丢。
|
|
|
|
|
+
|
|
|
|
|
+ private static String pendingDeepLink = null;
|
|
|
|
|
+ private static FmodeNotifyPlugin instance = null;
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public void load() {
|
|
|
|
|
+ instance = this;
|
|
|
|
|
+ /* 冷启动场景:Intent 早于 plugin 创建,这里补取一次 */
|
|
|
|
|
+ if (MainActivity.pendingDeepLink != null) {
|
|
|
|
|
+ pendingDeepLink = MainActivity.pendingDeepLink;
|
|
|
|
|
+ MainActivity.pendingDeepLink = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** JS 主动取(App 启动后立刻调一次),取完即清 */
|
|
|
|
|
+ @PluginMethod
|
|
|
|
|
+ public void getPendingDeepLink(PluginCall call) {
|
|
|
|
|
+ String link = pendingDeepLink;
|
|
|
|
|
+ pendingDeepLink = null;
|
|
|
|
|
+ JSObject ret = new JSObject();
|
|
|
|
|
+ ret.put("deepLink", link);
|
|
|
|
|
+ call.resolve(ret);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Activity.onNewIntent 热启动时调用 */
|
|
|
|
|
+ static void pushDeepLink(String link) {
|
|
|
|
|
+ if (link == null) return;
|
|
|
|
|
+ if (instance == null) {
|
|
|
|
|
+ /* 插件还没起来:暂存,等 JS 调 getPendingDeepLink 时取 */
|
|
|
|
|
+ pendingDeepLink = link;
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ JSObject data = new JSObject();
|
|
|
|
|
+ data.put("deepLink", link);
|
|
|
|
|
+ instance.notifyListeners("deepLink", data);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|