Forráskód Böngészése

fix(notify): 创建通知渠道,修复通知静默丢失

## 问题(最隐蔽的一个)
`schedule()` 返回成功、无任何报错,但通知栏**什么都没有**。
最容易误判成「权限没开」——实际权限完全正常(granted=true)。

根因:我们指定了 channelId: 'fmode-default',但该渠道**从未创建**。
Android 8.0+ 规定目标渠道不存在时,通知被系统**静默丢弃**,
插件不做校验,JS 侧看不出任何失败。

## 修复
发通知前先 createChannel(幂等,用 channelReady 缓存避免重复跨桥):
  id: fmode-default / importance: 4(HIGH,有声音且弹横幅) /
  visibility: 1(PUBLIC,锁屏可见) / vibration: true

## 验证
真机:createChannel 后立刻能收到「Fmode 通知测试」通知,
图标/标题/正文/时间均正确 ✅

## 附带发现(如实记录,未在本轮解决)
App 在后台时 WebView 的 JS 会被系统暂停,TaskPulseService 的轮询停摆,
因此「App 在后台时任务完成」不会触发通知 —— 这与既有的产品裁决一致
(只做前台/后台存活时的通知),但实测表明 Android 16 上后台 JS 暂停得很
彻底,实际效果接近「仅前台」。需要真正的到达提醒,必须走服务端推送。

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 4 órája
szülő
commit
a9a787f380
5 módosított fájl, 43 hozzáadás és 122 törlés
  1. 0 28
      .tmp-details.mjs
  2. 0 21
      .tmp-drawer.mjs
  3. 0 46
      .tmp-flow3.mjs
  4. 0 24
      .tmp-routes.mjs
  5. 43 3
      src/app/core/app-notification.service.ts

+ 0 - 28
.tmp-details.mjs

@@ -1,28 +0,0 @@
-import { chromium } from 'playwright';
-const base = 'http://localhost:4200';
-const b = await chromium.launch({ channel: 'msedge' });
-const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true,
-  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' });
-const p = await ctx.newPage();
-const errs = [];
-p.on('pageerror', e => errs.push(e.message));
-await p.goto(base + '/login'); await p.waitForTimeout(900);
-await p.locator('input').nth(0).fill('13800000000');
-await p.getByRole('button', { name: /获取验证码/ }).first().click(); await p.waitForTimeout(400);
-await p.locator('input').nth(1).fill('123456');
-await p.getByRole('button', { name: /^登\s*录$/ }).first().click(); await p.waitForTimeout(3500);
-// 从列表页点进真实详情
-await p.goto(base + '/mobile/projects'); await p.waitForTimeout(1500);
-await p.locator('.card.project').first().click(); await p.waitForTimeout(1200);
-console.log('project url', p.url().replace(base, ''));
-await p.screenshot({ path: '.tmp-shots/project-real.png' });
-await p.goto(base + '/mobile/tasks'); await p.waitForTimeout(1500);
-await p.locator('.card.task').first().click(); await p.waitForTimeout(1200);
-console.log('task url', p.url().replace(base, ''));
-await p.screenshot({ path: '.tmp-shots/task-real.png' });
-await p.goto(base + '/mobile/contacts'); await p.waitForTimeout(1500);
-await p.locator('.row').first().click(); await p.waitForTimeout(1500);
-console.log('agent url', p.url().replace(base, ''));
-await p.screenshot({ path: '.tmp-shots/agent-real.png' });
-console.log('errs', [...new Set(errs)].slice(0, 5));
-await b.close();

+ 0 - 21
.tmp-drawer.mjs

@@ -1,21 +0,0 @@
-import { chromium } from 'playwright';
-const base = 'http://localhost:4200';
-const b = await chromium.launch({ channel: 'msedge' });
-const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true,
-  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' });
-const p = await ctx.newPage();
-await p.goto(base + '/login'); await p.waitForTimeout(900);
-await p.locator('input').nth(0).fill('13800000000');
-await p.getByRole('button', { name: /获取验证码/ }).first().click(); await p.waitForTimeout(400);
-await p.locator('input').nth(1).fill('123456');
-await p.getByRole('button', { name: /^登\s*录$/ }).first().click(); await p.waitForTimeout(3500);
-// 建一条会话
-await p.getByRole('button', { name: '新建对话' }).first().click(); await p.waitForTimeout(700);
-await p.locator('.pick-row').first().click(); await p.waitForTimeout(2500);
-await p.locator('textarea').first().fill('抽屉测试'); await p.locator('textarea').first().press('Enter'); await p.waitForTimeout(2500);
-await p.goto(base + '/mobile/chat-index'); await p.waitForTimeout(2500);
-const txt = await p.evaluate(() => document.body.innerText);
-console.log('LIST:', txt.slice(0, 200).replace(/\n/g, ' | '));
-await p.getByRole('button', { name: '打开会话抽屉' }).click(); await p.waitForTimeout(900);
-console.log('DRAWER:', (await p.evaluate(() => document.body.innerText)).slice(0, 300).replace(/\n/g, ' | '));
-await b.close();

+ 0 - 46
.tmp-flow3.mjs

@@ -1,46 +0,0 @@
-import { chromium } from 'playwright';
-const base = 'http://localhost:4200';
-const b = await chromium.launch({ channel: 'msedge' });
-const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true,
-  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' });
-const p = await ctx.newPage();
-const errs = [];
-p.on('pageerror', e => errs.push(e.message));
-p.on('console', m => { if (m.type() === 'error') errs.push(m.text().slice(0, 140)); });
-const r = {};
-await p.goto(base + '/login'); await p.waitForTimeout(900);
-await p.locator('input').nth(0).fill('13800000000');
-await p.getByRole('button', { name: /获取验证码/ }).first().click(); await p.waitForTimeout(400);
-await p.locator('input').nth(1).fill('123456');
-await p.getByRole('button', { name: /^登\s*录$/ }).first().click(); await p.waitForTimeout(3500);
-r.landing = p.url().replace(base, '');
-// 新建对话
-await p.getByRole('button', { name: '新建对话' }).first().click(); await p.waitForTimeout(700);
-r.pick = await p.locator('.pick-row').count();
-await p.locator('.pick-row').first().click(); await p.waitForTimeout(2500);
-r.afterNew = p.url().replace(base, '').slice(0, 60);
-// 发消息
-const ta = p.locator('textarea').first();
-await ta.fill('你好,做个测试'); await ta.press('Enter'); await p.waitForTimeout(2500);
-r.sent = (await p.evaluate(() => document.body.innerText)).includes('你好,做个测试');
-await p.screenshot({ path: '.tmp-shots/chat.png' });
-// 抽屉(会话列表页顶栏 ☰)
-await p.goto(base + '/mobile/chat-index'); await p.waitForTimeout(1800);
-await p.getByRole('button', { name: '打开会话抽屉' }).click(); await p.waitForTimeout(800);
-r.drawer = (await p.evaluate(() => document.body.innerText)).includes('新建对话') && (await p.evaluate(() => document.body.innerText)).includes('更多');
-await p.locator('button:has-text("更多")').first().click(); await p.waitForTimeout(400);
-r.moreList = (await p.evaluate(() => document.body.innerText)).includes('成果');
-await p.screenshot({ path: '.tmp-shots/drawer.png' });
-await p.keyboard.press('Escape');
-// 详情页
-for (const [route, name] of [['projects/pr-0', 'project-detail'], ['tasks/t-0', 'task-detail']]) {
-  await p.goto(`${base}/mobile/${route}`); await p.waitForTimeout(1500);
-  await p.screenshot({ path: `.tmp-shots/${name}.png` });
-}
-// 配色切换
-await p.goto(base + '/mobile/mine'); await p.waitForTimeout(1200);
-await p.locator('.pal').nth(1).click(); await p.waitForTimeout(500);
-r.palette = await p.evaluate(() => document.documentElement.dataset.palette);
-await p.screenshot({ path: '.tmp-shots/mine-ice.png' });
-console.log(JSON.stringify({ r, errs: [...new Set(errs)].slice(0, 6) }, null, 1));
-await b.close();

+ 0 - 24
.tmp-routes.mjs

@@ -1,24 +0,0 @@
-import { chromium } from 'playwright';
-const base = 'http://localhost:4200';
-const b = await chromium.launch({ channel: 'msedge' });
-const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true,
-  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1' });
-const p = await ctx.newPage();
-const errs = [];
-p.on('pageerror', e => errs.push(e.message));
-p.on('console', m => { if (m.type() === 'error') errs.push(m.text().slice(0, 140)); });
-await p.goto(base + '/login'); await p.waitForTimeout(1000);
-await p.locator('input').nth(0).fill('13800000000');
-await p.getByRole('button', { name: /获取验证码/ }).first().click(); await p.waitForTimeout(400);
-await p.locator('input').nth(1).fill('123456');
-await p.getByRole('button', { name: /^登\s*录$/ }).first().click(); await p.waitForTimeout(3500);
-const routes = ['chat-index', 'contacts', 'projects', 'tasks', 'events', 'workbench', 'deliverables', 'mine'];
-const out = [];
-for (const r of routes) {
-  await p.goto(`${base}/mobile/${r}`); await p.waitForTimeout(1800);
-  const info = await p.evaluate(() => ({ len: document.body.innerText.trim().length, over: document.documentElement.scrollWidth > innerWidth + 1, text: document.body.innerText.slice(0, 40).replace(/\n/g, ' ') }));
-  await p.screenshot({ path: `.tmp-shots/${r}.png` });
-  out.push([r, info.len, info.over, info.text]);
-}
-console.log(JSON.stringify({ out, errs: [...new Set(errs)].slice(0, 8) }, null, 1));
-await b.close();

+ 43 - 3
src/app/core/app-notification.service.ts

@@ -2,6 +2,9 @@ import { Injectable, inject } from '@angular/core';
 import { isNativeApp } from './api/platform-proxy.service';
 import { DeepLinkService } from './deep-link.service';
 
+/** 通知渠道 id(Android 8+ 必须先创建同名渠道,否则通知被静默丢弃) */
+const NOTIFY_CHANNEL_ID = 'fmode-default';
+
 /** 一条要弹出的通知 */
 export interface AppNotice {
   /** 标题(通知栏第一行) */
@@ -173,6 +176,7 @@ export class AppNotificationService {
 
     try {
       const { LocalNotifications } = await import('@capacitor/local-notifications');
+      await this.ensureChannel(LocalNotifications);
       await LocalNotifications.schedule({
         notifications: [
           {
@@ -184,7 +188,7 @@ export class AppNotificationService {
             /* 点击后自动清除,不堆积 */
             autoCancel: true,
             smallIcon: 'ic_stat_icon_config_sample',
-            channelId: 'fmode-default',
+            channelId: NOTIFY_CHANNEL_ID,
             /*
              * 🔴 必须显式关掉「精确通知」(2026-11-15 真机踩坑)。
              *
@@ -210,9 +214,45 @@ export class AppNotificationService {
     }
   }
 
+  /**
+   * 创建通知渠道(Android 8+ 必需,2026-11-15 真机踩坑)
+   *
+   * 🔴 症状:`schedule()` 返回成功、无任何报错,但**通知栏里什么都没有**。
+   *    原因是我们指定了 `channelId: fmode-default`,而这个渠道**从未被创建**。
+   *    Android 8.0+ 规定:目标渠道不存在时,通知被系统**静默丢弃**——
+   *    插件不做校验,JS 侧也就完全看不到失败。
+   *
+   *    这是最容易被误判成「权限没开」的一类问题,实际权限是完全正常的。
+   *
+   * 幂等:重复调用只会更新已有渠道的属性,可以每次发通知前都调一次,
+   * 也可以只在首次调 —— 这里用 `channelReady` 缓存,省一次跨桥调用。
+   */
+  private channelReady = false;
+
+  private async ensureChannel(
+    LN: typeof import('@capacitor/local-notifications').LocalNotifications,
+  ): Promise<void> {
+    if (this.channelReady) return;
+    try {
+      await LN.createChannel({
+        id: NOTIFY_CHANNEL_ID,
+        name: '消息与任务',
+        description: '容器任务完成与新消息提醒',
+        importance: 4, // HIGH:有声音且弹出横幅,否则只有抽屉里一条
+        visibility: 1, // PUBLIC:锁屏也显示内容
+        vibration: true,
+      });
+      this.channelReady = true;
+    } catch {
+      /*
+       * 创建失败不阻断发送:可能是低版本 Android(无渠道概念),
+       * 那种情况下不传 channelId 也能正常弹。
+       */
+    }
+  }
+
   /** 同一 key 复用同一个 id → 新通知覆盖旧通知 */
-  private idForKey(key: string): number {
-    const hit = this.idsByKey.get(key);
+  private idForKey(key: string): number {    const hit = this.idsByKey.get(key);
     if (hit !== undefined) return hit;
     const id = this.freshId();
     this.idsByKey.set(key, id);