瀏覽代碼

fix(notify): 通知不再误跳闹钟页 + 新增通知权限入口与引导

## 问题
用户首次授权通知后,小米系统跳转到「闹钟和提醒」设置页,用户不解。

根因在插件源码 `LocalNotification.kt:185`:
    n.isExactNotification = jsonObject.getBoolean("isExactNotification", true)
**默认值是 true** —— 不传该字段,插件把它当精确定时闹钟,于是走
ACTION_REQUEST_SCHEDULE_EXACT_ALARM 强制跳闹钟页。
从 manifest 移除 SCHEDULE_EXACT_ALARM **不能阻止**(跳转在 schedule() 里主动发起)。

## 修复
- schedule() 显式传 `isExactNotification: false`(我们是即时通知,不需要毫秒级定时)
- 「我的」页新增「通知」区块:显示当前状态(已开启/未开启),点击可去开启
- 权限被拒后系统不再弹窗 → 引导到系统设置页

## 新增原生插件 AppSettingsPlugin
`@capacitor/app` v8 没有 openSettings(),故自建最小插件,
用 ACTION_APPLICATION_DETAILS_SETTINGS 打开本 App 的应用信息页
(第一屏即有「通知管理」入口)。

为什么不用 ACTION_APP_NOTIFICATION_SETTINGS:需要额外 extras,
部分 ROM 不支持,跳错会落到空白页。

## 验证(真机)
- 点击「消息与任务通知(未开启)」→ 打开的是 Fmode「应用信息」页,
  内含「通知管理 · 不允许」→ 进入后是 Fmode 通知设置页,
  「允许通知」开关在最上方 ✅
- 不再跳到「闹钟和提醒」✅

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 4 小時之前
父節點
當前提交
712c49bf46

+ 49 - 0
android/app/src/main/java/cn/fmode/app/AppSettingsPlugin.java

@@ -0,0 +1,49 @@
+package cn.fmode.app;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.provider.Settings;
+
+import com.getcapacitor.JSObject;
+import com.getcapacitor.Plugin;
+import com.getcapacitor.PluginCall;
+import com.getcapacitor.PluginMethod;
+import com.getcapacitor.annotation.CapacitorPlugin;
+
+/**
+ * 打开本 App 的系统设置页(2026-11-15)
+ *
+ * ## 为什么需要
+ *
+ * Android 13+ 通知权限一旦被用户拒绝,系统**不再弹窗**,只能去系统设置里手动开。
+ * Capacitor 官方 `@capacitor/app` **没有** `openSettings()` 方法(实测 v8.1.2),
+ * 所以这里补一个最小的原生插件,走系统标准 Intent。
+ *
+ * ## 用哪个 Intent
+ *
+ * `Settings.ACTION_APPLICATION_DETAILS_SETTINGS` + `package:` URI
+ * = 本 App 的应用信息页。这是 Android 官方推荐的「引导用户开权限」入口,
+ * 所有主流 App(微信/淘宝)引导开通知都用它。
+ *
+ * 🔴 不用 `ACTION_APP_NOTIFICATION_SETTINGS`(通知子页):
+ *    它需要额外的 extras(`EXTRA_APP_PACKAGE`),且部分 ROM 不支持,
+ *    跳错会落到空白页。应用信息页第一屏就有「通知」,路径同样短且稳定。
+ */
+@CapacitorPlugin(name = "AppSettings")
+public class AppSettingsPlugin extends Plugin {
+
+    @PluginMethod
+    public void open(PluginCall call) {
+        try {
+            Intent intent = new Intent(
+                Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+                Uri.parse("package:" + getContext().getPackageName())
+            );
+            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+            getContext().startActivity(intent);
+            call.resolve(new JSObject());
+        } catch (Exception e) {
+            call.reject("无法打开系统设置: " + e.getMessage());
+        }
+    }
+}

+ 10 - 1
android/app/src/main/java/cn/fmode/app/MainActivity.java

@@ -1,5 +1,14 @@
 package cn.fmode.app;
 
+import android.os.Bundle;
+
 import com.getcapacitor.BridgeActivity;
 
-public class MainActivity extends BridgeActivity {}
+public class MainActivity extends BridgeActivity {
+    @Override
+    public void onCreate(Bundle savedInstanceState) {
+        // 注册自定义插件(2026-11-15):用于跳转系统设置页引导用户开通知权限
+        registerPlugin(AppSettingsPlugin.class);
+        super.onCreate(savedInstanceState);
+    }
+}

+ 71 - 0
src/app/core/app-notification.service.ts

@@ -80,6 +80,55 @@ export class AppNotificationService {
     }
   }
 
+  /**
+   * 权限被拒后,把用户**引导到系统通知设置页**(2026-11-15)。
+   *
+   * ## 为什么需要
+   *
+   * Android 13+ 通知权限如果被用户点了「拒绝」,**再次调用 `requestPermissions()`
+   * 不会再弹窗**(系统会直接返回 denied),必须去系统设置里手动打开。
+   * 不做引导的话,用户会觉得「这个 App 就是不给我推送」,也不知道去哪开。
+   *
+   * ## 跳哪个页面
+   *
+   * 用 `App.openSettings()` 打开**本 App 的系统设置页**。
+   * 为什么不直接跳「通知」子页:那需要 `Settings.ACTION_APP_NOTIFICATION_SETTINGS`
+   * (Android 原生 Intent),Capacitor 没有暴露;而 App 设置页里
+   * 第一屏就是「通知」入口,路径很短,也不会跳错到别的设置页。
+   *
+   * 🔴 对照真实踩坑:`@capacitor/local-notifications` 因为
+   * `isExactNotification` 默认为 true,会**主动跳「闹钟和提醒」**页 —— 那才是错的。
+   */
+  async openNotificationSettings(): Promise<void> {
+    if (!isNativeApp()) return;
+    try {
+      /*
+       * 走我们自己的原生插件 `AppSettingsPlugin`(见 android/.../AppSettingsPlugin.java)。
+       * `@capacitor/app` v8 **没有** `openSettings()`,所以必须自建。
+       * 插件内部用 `ACTION_APPLICATION_DETAILS_SETTINGS` 打开本 App 的设置页。
+       */
+      const { registerPlugin } = await import('@capacitor/core');
+      const AppSettings = registerPlugin<{ open(): Promise<void> }>('AppSettings');
+      await AppSettings.open();
+    } catch {
+      /* 插件未注册 / 非 Android:静默,不崩 */
+    }
+  }
+
+  /** 当前是否已授权(不发请求,只查询;未知时返回 null) */
+  async isGranted(): Promise<boolean | null> {
+    if (!isNativeApp()) return null;
+    if (this.granted !== null) return this.granted;
+    try {
+      const { LocalNotifications } = await import('@capacitor/local-notifications');
+      const cur = await LocalNotifications.checkPermissions();
+      this.granted = cur.display === 'granted';
+      return this.granted;
+    } catch {
+      return null;
+    }
+  }
+
   /**
    * 申请通知权限。
    *
@@ -95,6 +144,11 @@ export class AppNotificationService {
         this.granted = true;
         return true;
       }
+      /*
+       * 已被拒绝过一次 → 系统不再弹窗,直接返回 denied。
+       * 此时 `this.granted` 会记成 false,上层可用 `isGranted()` 区分
+       * 「还没问过」与「问过被拒」,从而决定是否给引导提示。
+       */
       const res = await LocalNotifications.requestPermissions();
       this.granted = res.display === 'granted';
       return this.granted;
@@ -131,6 +185,23 @@ export class AppNotificationService {
             autoCancel: true,
             smallIcon: 'ic_stat_icon_config_sample',
             channelId: 'fmode-default',
+            /*
+             * 🔴 必须显式关掉「精确通知」(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,
           },
         ],
       });

+ 62 - 0
src/app/mobile/pages/mine/mine.ts

@@ -1,3 +1,5 @@
+import { AppNotificationService } from '../../../core/app-notification.service';
+import { isNativeApp } from '../../../core/api/platform-proxy.service';
 import { ChatService } from '../../../core/chat.service';
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { Router } from '@angular/router';
@@ -83,6 +85,27 @@ import { UiMDialog } from '../../../ui/mobile/components/dialog/dialog';
         </ul>
       </section>
 
+      <!--
+        通知(2026-11-15):把「开不开系统通知」这件事摆到明面上。
+        🔴 为什么要有这一项:Android 13+ 首次拒绝通知权限后,系统**不再弹窗**,
+           用户会一直以为「这个 App 就是不推送」,也不知道去哪改。
+           这里给一个明确入口 + 状态显示,点一下就去系统设置。
+      -->
+      @if (isNative()) {
+        <section class="m-section">
+          <h2 class="m-section-title">通知</h2>
+          <div class="m-card">
+            <button type="button" class="m-row" (click)="onNotificationRow()">
+              <span class="m-row-body">
+                <span class="m-row-t1">消息与任务通知</span>
+                <span class="m-row-sub">{{ notifyStateText() }}</span>
+              </span>
+              <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
+            </button>
+          </div>
+        </section>
+      }
+
       <section class="m-section">
         <h2 class="m-section-title">外观</h2>
         <div class="m-card">
@@ -431,6 +454,7 @@ export class MbMinePage {
   constructor() {
     // 字号偏好按用户存储,进入设置页时载入(幂等)
     this.font.load();
+    this.loadNotificationState();
   }
 
   /** Token 只展示尾部 4 位 —— 完整令牌不应出现在屏幕上 */
@@ -465,6 +489,44 @@ export class MbMinePage {
     this.theme.toggleMode();
   }
 
+  // ---------- 通知权限(2026-11-15) ----------
+
+  private readonly notify = inject(AppNotificationService);
+  /** 通知权限状态:null = 未知/查询中,true = 已开,false = 未开 */
+  protected readonly notifyGranted = signal<boolean | null>(null);
+
+  /** 是否原生壳(浏览器里不显示「通知」入口 —— 浏览器没有这个系统权限) */
+  protected readonly isNative = signal(isNativeApp());
+
+  /** 进页面时查一次通知权限状态(构造函数里调用,不另开第二个 constructor) */
+  private loadNotificationState(): void {
+    void this.notify.isGranted().then((g) => this.notifyGranted.set(g));
+  }
+
+  protected notifyStateText(): string {
+    const g = this.notifyGranted();
+    if (g === null) return '检查中…';
+    return g ? '已开启' : '未开启 · 点此去开启';
+  }
+
+  /**
+   * 点「通知」行:
+   *   · 已开 → 去系统设置(可关闭)
+   *   · 未开 → 先申请;被系统拒绝(不再弹窗)则引导去设置页
+   */
+  protected async onNotificationRow(): Promise<void> {
+    if (this.notifyGranted() === true) {
+      await this.notify.openNotificationSettings();
+      return;
+    }
+    const ok = await this.notify.request();
+    this.notifyGranted.set(ok);
+    if (!ok) {
+      /* 系统不再弹窗了 → 只能引导用户自己去设置里开 */
+      await this.notify.openNotificationSettings();
+    }
+  }
+
   /** 字号滑块:吸附到最近的档位(与桌面设置同一组档位) */
   protected onFont(e: Event): void {
     const v = Number((e.target as HTMLInputElement).value);