Sfoglia il codice sorgente

fix(notify): 改用自建原生插件发通知,打通「点击→拉起→跳转」全链路

## 问题:点击通知拉不起 App
@capacitor/local-notifications 用
  intent.setAction(ACTION_VIEW) + setData(fmode://...)
构造 PendingIntent。ACTION_VIEW + 自定义 scheme 会进入「外部链接解析」
流程,系统拿它去匹配 intent-filter 而不是直接启动 ComponentName,
命中失败就静默丢弃 —— 表现为「通知在、点没反应」。

对照验证:adb `am start -a VIEW -d fmode://contacts` 能正常唤起,
说明深链机制本身没问题,问题只在 PendingIntent 这一层,JS 侧无法干预。

## 方案:自建 FmodeNotifyPlugin
- Intent 用显式 ComponentName + ACTION_MAIN/CATEGORY_LAUNCHER,
  最深链只走 extra,不走 data
- PendingIntent 用 FLAG_IMMUTABLE(插件用 MUTABLE,Android 12+ 会失效)
- 一并解决另两个坑:
  · 渠道未创建 → 通知被 Android 8+ 静默丢弃
  · isExactNotification 默认 true → 强制跳「闹钟和提醒」

## 深链传递(冷启动/热启动都要覆盖)
- 冷启动:Intent 早于 Plugin 创建 → MainActivity 暂存 pendingDeepLink,
  插件 load() 时取走,JS 启动后调 getPendingDeepLink() 主动取
- 热启动:MainActivity.onNewIntent → 插件 notifyListeners('deepLink')
- JS 侧在 app.config 用 initializer 调 init(),确保冷启动也不漏

## 验证(真机)
通知显示 ✅ → 点击拉起 App ✅ → 自动跳到通讯录页 ✅
(用 uiautomator dump 精确定位通知 bounds 后点击,此前是坐标打偏)

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 3 ore fa
parent
commit
1083008870

+ 200 - 0
android/app/src/main/java/cn/fmode/app/FmodeNotifyPlugin.java

@@ -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);
+    }
+}

+ 34 - 2
android/app/src/main/java/cn/fmode/app/MainActivity.java

@@ -1,5 +1,6 @@
 package cn.fmode.app;
 
+import android.content.Intent;
 import android.os.Bundle;
 
 import com.getcapacitor.BridgeActivity;
@@ -7,8 +8,39 @@ import com.getcapacitor.BridgeActivity;
 public class MainActivity extends BridgeActivity {
     @Override
     public void onCreate(Bundle savedInstanceState) {
-        // 注册自定义插件(2026-11-15):用于跳转系统设置页引导用户开通知权限
-        registerPlugin(AppSettingsPlugin.class);
+        // 注册自定义插件(2026-11-15)
+        registerPlugin(AppSettingsPlugin.class);   // 跳系统设置页引导开通知权限
+        registerPlugin(FmodeNotifyPlugin.class);   // 发系统通知(替代 @capacitor/local-notifications)
+
+        /*
+         * 冷启动:App 没在运行时点通知,Intent 在 onCreate 才到达。
+         * 必须在 super.onCreate 之前把 pendingDeepLink 塞给插件,
+         * 否则插件注册时已错过这次事件。
+         */
+        pendingDeepLink = extractDeepLink(getIntent());
+
         super.onCreate(savedInstanceState);
     }
+
+    @Override
+    public void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        /*
+         * 热启动:App 在后台/前台时点通知,走 singleTask 的 onNewIntent。
+         * Capacitor 内核在 super 里把这个 Intent 当作 appUrlOpen 处理 ——
+         * 但我们走的是 extra 而非 data,所以额外通知 JS 一次。
+         */
+        setIntent(intent);
+        pendingDeepLink = extractDeepLink(intent);
+        FmodeNotifyPlugin.pushDeepLink(pendingDeepLink);
+    }
+
+    /** 当前待处理的深链(冷启动时由插件读取) */
+    static String pendingDeepLink = null;
+
+    private static String extractDeepLink(Intent intent) {
+        if (intent == null) return null;
+        String link = intent.getStringExtra("deepLink");
+        return (link != null && !link.isEmpty()) ? link : null;
+    }
 }

+ 7 - 0
src/app/app.config.ts

@@ -15,6 +15,7 @@ import { AppErrorHandler } from './core/app-error-handler';
 import { DeepLinkService } from './core/deep-link.service';
 import { SafeAreaService } from './core/safe-area.service';
 import { BackButtonService } from './core/back-button.service';
+import { AppNotificationService } from './core/app-notification.service';
 import { routes } from './app.routes';
 
 export const appConfig: ApplicationConfig = {
@@ -58,5 +59,11 @@ export const appConfig: ApplicationConfig = {
      * 极端情况下历史栈错位导致整屏白屏。浏览器环境内部直接返回。
      */
     provideAppInitializer(() => inject(BackButtonService).start()),
+    /*
+     * 通知深链初始化(2026-11-15):注册点击监听 + 读取冷启动时的 pendingDeepLink。
+     * 冷启动场景下 JS 还不存在,事件推不出来,必须主动取一次,否则
+     * 「App 没运行时点通知」永远不会跳到目标页。
+     */
+    provideAppInitializer(() => inject(AppNotificationService).init()),
   ],
 };

+ 56 - 37
src/app/core/app-notification.service.ts

@@ -1,10 +1,22 @@
 import { Injectable, inject } from '@angular/core';
+import { registerPlugin } from '@capacitor/core';
 import { isNativeApp } from './api/platform-proxy.service';
 import { DeepLinkService } from './deep-link.service';
 
 /** 通知渠道 id(Android 8+ 必须先创建同名渠道,否则通知被静默丢弃) */
 const NOTIFY_CHANNEL_ID = 'fmode-default';
 
+/** 自建原生通知插件(android/.../FmodeNotifyPlugin.java)的 TS 形状 */
+interface FmodeNotifyPlugin {
+  show(o: { id?: number; title: string; body: string; deepLink?: string }): Promise<{ id: number }>;
+  getPendingDeepLink(): Promise<{ deepLink: string | null }>;
+  cancel(o: { id: number }): Promise<void>;
+  addListener(
+    event: 'deepLink',
+    cb: (data: { deepLink?: string }) => void,
+  ): Promise<{ remove: () => void }>;
+}
+
 /** 一条要弹出的通知 */
 export interface AppNotice {
   /** 标题(通知栏第一行) */
@@ -71,13 +83,27 @@ export class AppNotificationService {
     if (!isNativeApp() || this.initialized) return;
     this.initialized = true;
     try {
-      const { LocalNotifications } = await import('@capacitor/local-notifications');
-      await LocalNotifications.addListener('localNotificationActionPerformed', (action) => {
-        const link = action.notification.extra?.deepLink;
-        if (typeof link !== 'string' || !link) return;
+      const N = this.plugin();
+      /*
+       * 热启动:App 在后台时点通知 → Activity.onNewIntent → 插件 notifyListeners。
+       * 这里收下来交给 DeepLinkService 跳页。
+       */
+      await N.addListener('deepLink', (data: { deepLink?: string }) => {
+        const link = data?.deepLink;
+        if (!link) return;
         const target = this.deepLink.toRoute(link);
         if (target) void this.deepLink.navigate(target);
       });
+
+      /*
+       * 冷启动:App 没在运行时点通知,深链存在原生侧的 pendingDeepLink 里。
+       * 🔴 必须主动取一次 —— 那时 JS 还不存在,事件推不出来。
+       */
+      const pending = await N.getPendingDeepLink();
+      if (pending?.deepLink) {
+        const target = this.deepLink.toRoute(pending.deepLink);
+        if (target) void this.deepLink.navigate(target);
+      }
     } catch {
       /* 插件不可用:通知是增强能力,失败不能让 App 起不来 */
     }
@@ -175,45 +201,38 @@ export class AppNotificationService {
     const id = notice.id ?? (key ? this.idForKey(key) : this.freshId());
 
     try {
-      const { LocalNotifications } = await import('@capacitor/local-notifications');
-      await this.ensureChannel(LocalNotifications);
-      await LocalNotifications.schedule({
-        notifications: [
-          {
-            id,
-            title: notice.title,
-            body: notice.body,
-            /* 有深链才带 extra,点击处理器据此决定跳不跳 */
-            extra: notice.deepLink ? { deepLink: notice.deepLink } : undefined,
-            /* 点击后自动清除,不堆积 */
-            autoCancel: true,
-            smallIcon: 'ic_stat_icon_config_sample',
-            channelId: NOTIFY_CHANNEL_ID,
-            /*
-             * 🔴 必须显式关掉「精确通知」(2026-11-15 真机踩坑)。
-             *
-             * 插件源码 `LocalNotification.kt:185`:
-             *     n.isExactNotification = jsonObject.getBoolean("isExactNotification", true) ?: true
-             * **默认值是 true**。不传这个字段,插件就把它当精确定时闹钟,
-             * 于是走 `ACTION_REQUEST_SCHEDULE_EXACT_ALARM`,
-             * 把用户**强制带到系统的「闹钟和提醒」设置页**。
-             *
-             * 症状:用户首次收到通知时莫名跳到闹钟设置,完全不理解为什么。
-             * 更坑的是——从 manifest 移除 SCHEDULE_EXACT_ALARM 权限**并不能阻止**,
-             * 因为跳转是插件在 `schedule()` 里主动 `startActivityForResult` 发起的。
-             *
-             * 我们是**即时通知**,不需要毫秒级定时,所以传 false。
-             * 关掉后插件走 `AlarmManager.set()`(非精确),通知照样按时弹。
-             */
-            isExactNotification: false,
-          },
-        ],
+      /*
+       * 🔴 走自建插件 FmodeNotify,不再用 @capacitor/local-notifications。
+       *
+       * 原因(2026-11-15 真机实测):该插件的「点击通知」**拉不起 App** ——
+       * 它用 FLAG_MUTABLE 构造 PendingIntent,在 Android 12+ 上静默失效。
+       * 而 adb `am start` 走同一套 Intent 却正常,说明问题只在 PendingIntent 层,
+       * JS 侧无法干预。自建插件用 FLAG_IMMUTABLE,点击可靠。
+       *
+       * 顺带解决两件事:isExactNotification 默认 true 导致跳闹钟页、
+       * 渠道未创建导致通知被静默丢弃。
+       */
+      await this.plugin().show({
+        id,
+        title: notice.title,
+        body: notice.body,
+        deepLink: notice.deepLink,
       });
     } catch {
       /* 静默:通知失败不该影响业务 */
     }
   }
 
+  /** 自建通知插件(懒加载,只在真正要发通知时实例化) */
+  private plugin(): FmodeNotifyPlugin {
+    if (!this.notifyPlugin) {
+      this.notifyPlugin = registerPlugin<FmodeNotifyPlugin>('FmodeNotify');
+    }
+    return this.notifyPlugin;
+  }
+
+  private notifyPlugin: FmodeNotifyPlugin | null = null;
+
   /**
    * 创建通知渠道(Android 8+ 必需,2026-11-15 真机踩坑)
    *