/* * 推送客户端(平台那半)—— Push Kit 取 token → 上报给网关 → 点通知跳转的后半段。 * * 设计原则(pi `1ce5b03a` 已实测的服务端事实): * · POST/GET/DELETE 都在 `/me/*`,同一套 Bearer 鉴权;DELETE **也读 body**(不是 query、不是 path 参数); * · `provider` 只做**形状**校验(`^[a-z0-9_-]{1,32}$`),**不做白名单** —— 所以这里不硬编码"只有 hms"; * · 同一 token 换账号是**转移**(不是并存)⇒ 上报标记必须按 `accountKey|token`,否则新账号永远不报、旧账号的登记已被转走; * · `enabled:false`(服务端没配通道)**不是错误**:静默跳过,别弹任何东西; * · 单批最多 10 个 token(服务端批量)—— 客户端一次只报自己一个,不涉及。 * * ★ 最重要的一条:**所有失败都静默**。 * 推送是"便利",不是主链 —— SSE 才是主通道。任何一步取不到、报不上、没权限, * 都只写日志,绝不打扰用户、绝不阻塞启动。这条写在这里是因为它**很容易被"顺手加个提示"破坏**, * 而那样做的后果是:一个本来只在后台失败的可选功能,变成用户每次启动都看到的报错。 */ import { pushService } from '@kit.PushKit'; import { notificationManager } from '@kit.NotificationKit'; import { preferences } from '@kit.ArkData'; import { hilog } from '@kit.PerformanceAnalysisKit'; import { BusinessError, deviceInfo } from '@kit.BasicServicesKit'; import { common } from '@kit.AbilityKit'; import { ApiClient } from './ApiClient'; import { AccountManager } from './AccountManager'; import { PROVIDER_HMS, DEFAULT_PUSH_ENABLED, PushRegisterBody, PushNotificationData, buildTokenBody, parseNotificationData, reportMarker, shouldReportToken, NotificationLedger, } from '../model/PushContract'; const DOMAIN: number = 0x0001; const TAG: string = 'PushService'; const STORE: string = 'push_store'; const MARKER_KEY: string = 'reported_marker'; const SETTING_KEY: string = 'push_enabled'; /* * ★ 2026-09-15:接入系统通知是**可配置项**(用户强调多次)。 * 默认 **关** —— 自部署的用户没有服务端推送凭证时,App 不取 token、不请求权限、不上报、不打网关。 * 开了以后才走 getToken → requestEnableNotification → 上报这一整条。 * 这条开关存在 PushService 里(而不是设置页 @State),是因为 onCreate(ability 阶段)要读它。 */ /** 点击跳转的目标(由通知 data 解析而来;只有两样东西要路由) */ export interface PushRoute { sessionId: string; mailId: string; } /** 上报的返回(服务端同时给 enabled/providers,所以**不需要**再 GET 一次 —— pi `1ce5b03a`) */ interface PushRegisterResponse { enabled?: boolean; providers?: string[]; } export class PushService { private static instance: PushService | null = null; /* * 点通知进来的**待处理目标**:ability 收到 want 时写这里,页面起来后读它并清空。 * 用一个静态格子而不是 AppStorage:ability 阶段拿不到页面状态,而静态格子两边都够得着。 */ static pendingRoute: PushRoute | undefined = undefined; /* * ★ 2026-09-17 真机实测补的**第二半**:光有"待处理格子"只够**冷启**。 * * 实测(模拟器,`aa start --ps data '{…open_mail…}'`):冷启能跳(页面刚挂载, * `aboutToAppear` 会读 pendingRoute);但**应用已在运行时点通知**(`onNewWant`) * 只把格子写上了,**没有任何东西会再读它** —— 那几个页面早就挂载完了、 * `aboutToAppear` 不会重跑,用户看到的是"点了通知,App 弹到前台,停在列表"。 * * 为什么不用页面生命周期兜(`onPageShow` 之类):这里要的是"**事件发生的那一刻**" * 而不是"页面每次可见时"—— 后者会在用户手动返回列表时反复触发跳转。 * 所以:注册一个回调,`onNewWant` 里直接叫醒正在监听的页面。 */ private static routeListener: ((route: PushRoute) => void) | undefined = undefined; /** 页面注册"点通知要跳转"的回调(同一时刻只留一个:栈只有一处)。 */ static setRouteListener(fn: (route: PushRoute) => void): void { PushService.routeListener = fn; } /** 页面退出时摘掉回调(否则会叫醒一个已销毁的页面)。 */ static clearRouteListener(): void { PushService.routeListener = undefined; } /** * 通知目标落地:**有人监听就立刻交出去,没人监听就留在格子里等页面来读**。 * * 两条路都要留着,因为两种启动方式各走一条: * · 冷启(进程不在):页面还没挂载 ⇒ 没人监听 ⇒ 留在格子里,`aboutToAppear` 取走; * · 热启(进程在、页面已挂载):有人监听 ⇒ 直接调,用户立刻看到那封信。 */ static deliverRoute(route: PushRoute): void { const fn: ((route: PushRoute) => void) | undefined = PushService.routeListener; if (fn !== undefined) { fn(route); return; } PushService.pendingRoute = route; } private context: common.UIAbilityContext; private account: AccountManager; private constructor(context: common.UIAbilityContext) { this.context = context; this.account = AccountManager.getInstance(context); } static getInstance(context: common.UIAbilityContext): PushService { if (PushService.instance === null) { PushService.instance = new PushService(context); } return PushService.instance; } /** * 读「接入系统通知」开关(**默认开** —— 见 PushContract.DEFAULT_PUSH_ENABLED)。 * 读不到(首次/异常)都返回开:推送是用户要能默认收到的功能, * 降级发生在服务端回 enabled:false 时(通道未配),不是由客户端默认关掉。 */ static isEnabled(context: common.Context): boolean { try { const store = preferences.getPreferencesSync(context, { name: STORE }); const v = store.getSync(SETTING_KEY, DEFAULT_PUSH_ENABLED); return v === true; } catch (e) { return DEFAULT_PUSH_ENABLED; } } /** 写开关(设置页 Toggle 回调;写入失败静默 —— 关着总比误开着安全,下次再试不迟) */ static setEnabled(context: common.Context, enabled: boolean): void { try { const store = preferences.getPreferencesSync(context, { name: STORE }); store.putSync(SETTING_KEY, enabled); store.flush(); } catch (e) { hilog.info(DOMAIN, TAG, '推送开关写不进去(静默):%{public}s', (e as BusinessError).message); } } /** * 取 Push Kit 的 token。**取不到就返回空串**(没装 HMS Core、没登录华为账号、没权限……都是正常情况)。 */ private async getToken(): Promise { try { const token: string = await pushService.getToken(); return token === undefined || token === null ? '' : token; } catch (e) { const err = e as BusinessError; hilog.info(DOMAIN, TAG, 'push token 取不到(静默,属正常):%{public}s', err.message); return ''; } } /** 申请通知权限(用户拒绝也不影响主链) */ async requestEnableNotification(): Promise { try { await notificationManager.requestEnableNotification(this.context); } catch (e) { const err = e as BusinessError; hilog.info(DOMAIN, TAG, '通知权限未开(静默):%{public}s', err.message); } } private async readMarker(): Promise { try { const store = await preferences.getPreferences(this.context, STORE); const v = await store.get(MARKER_KEY, ''); return typeof v === 'string' ? v : ''; } catch (e) { return ''; } } private async writeMarker(marker: string): Promise { try { const store = await preferences.getPreferences(this.context, STORE); await store.put(MARKER_KEY, marker); await store.flush(); } catch (e) { hilog.info(DOMAIN, TAG, '标记写不进去(静默):下次会重复上报一次,服务端是幂等的登记'); } } /** 设备名:取不到就给空串,body 里会省略这一项(不要造一个假名字) */ private deviceName(): string { try { const m: string = deviceInfo.productModel; return m === undefined || m === null ? '' : m; } catch (e) { return ''; } } /** * 上报本机 token。**幂等**:已经报过、且账号没换 ⇒ 直接返回(不打网络)。 * * 放在启动后调用(登录成功之后、或已有账号时),调用方**不需要** catch —— 它自己内部全静默。 */ async reportToken(api: ApiClient): Promise { /* * ★ 开关 gate(2026-09-15):"接入系统通知"是**可配置项**,默认关。 * 关 ⇒ 不取 token(不碰 Push Kit)、不申请权限(不弹系统弹窗)、不上报、不打网关 —— * 自部署用户没有服务端推送凭证时,这一整条都是无谓的开销。 * 这条 gate 在**最前**:getToken/requestEnableNotification 都在它后面。 */ if (!PushService.isEnabled(this.context)) { return; } // ★ 先申请通知权限再取 token:部分设备上 Push Kit getToken 会因通知权限未开而 // 返回空 / 报 1600004,先 requestEnableNotification 把权限前置到位。 await this.requestEnableNotification(); const token: string = await this.getToken(); if (token.length === 0) { return; // 没有 token 就没什么可报的(这不是错误) } const accountKey: string = this.account.getActiveId(); const lastMarker: string = await this.readMarker(); /* * ★ 换账号必须重报:服务端里同一 token 换账号是**转移**, * 所以"我报过没有"这个问题的答案**随账号变**(pi `1ce5b03a` 确认的行为)。 */ if (!shouldReportToken(lastMarker, accountKey, token)) { return; } /* * ★ `session_id` 传**空**(不传这个字段),不是"随手塞一个值"。 * * 服务端对这个字段的语义是"客户端**当前所在的会话**:点通知要回到那条会话里的那封信", * 并且**明确允许为空**("客户端还没进任何会话,此时通知只带 mail_id")。 * * 而这一步的时机决定它必然为空:`reportToken` 只在**登录成功**与**换账号**时跑, * 那时用户还没打开任何一条会话 —— 客户端也**根本没有"当前会话"这个状态** * 可以取(`MainPage` 不记它)。所以这里如实传空,而不是编一个。 * * ★ 这里原本传的是 `getActiveAccount()!.server` —— 那是**服务器地址** * (`AccountInfo.server`,值长这样:`https://mail.jianfgit.xyz/api/v1`), * 不是会话 id。服务端对这个字段**不做格式校验**(只 `TrimSpace`、且允许为空), * 所以它不会 400、不影响收信、也不进任何日志 —— * 只会在 `push_tokens` 里存一条**假的**会话 id,而 `GET /me/devices/push-token` * 还会把它**原样回给客户端**(`handler/push.go` 的 items 里带 `session_id`)。 * 投递本身暂不受影响:发通知用的是**邮件自己的** `session_id` * (`notify/mail.go` 构造 `push.NewMail{SessionID: m.SessionID}`), * 而 `dispatch` 只用 token 的 `Provider`/`Token`。 * ⇒ **这个字段存在的唯一目的就是"点通知回到那条会话",而它存的值是错的。** * "不会立刻炸"正是这种错最值得先修的原因:它不报错,只是让数据开始说谎。 * * 等客户端真的有了"当前会话"(打开某条会话时),再在这里补报一次真实 id —— * 那时 `session_id` 才有值可传。**现在传空是准确的,传 URL 是错的。** */ const sessionId: string = ''; const body: PushRegisterBody | undefined = buildTokenBody(PROVIDER_HMS, token, this.deviceName(), sessionId); if (body === undefined) { hilog.info(DOMAIN, TAG, 'body 形状不合法(静默,不发)'); return; } try { await api.post('/me/devices/push-token', body); await this.writeMarker(reportMarker(accountKey, token)); } catch (e) { const err = e as BusinessError; /* * 静默:401(没登录)/400(形状)/500(服务端)都不重试、不提示。 * 不重试的理由:这是"登记便利通道",不是数据;失败了下次启动会再试一次(标记没写成功)。 */ hilog.info(DOMAIN, TAG, '上报失败(静默):%{public}s', err.message); } } /** * 注销本机 token(退出登录时调用)。DELETE **带 body** —— 服务端就是这么定的。 * 返回的 `deleted:false`(本来就没登记)**不是错误**。 */ async unregister(api: ApiClient): Promise { const token: string = await this.getToken(); if (token.length === 0) { return; } const body: PushRegisterBody | undefined = buildTokenBody(PROVIDER_HMS, token, '', ''); if (body === undefined) { return; } try { await api.del('/me/devices/push-token', body); } catch (e) { const err = e as BusinessError; hilog.info(DOMAIN, TAG, '注销失败(静默):%{public}s', err.message); } await this.writeMarker(''); } /** * 点通知后的跳转目标。 * * `raw` 是通知 `data` 里的那个 JSON 字符串(`{type,mail_id,session_id,action:'open_mail'}`)。 * 解析不出来、或 action 不是 `open_mail` ⇒ 返回 undefined(**不做任何跳转**,也不报错: * 一条格式不认识的通知,最坏结果应当是"没反应",而不是"跳到一个空页面")。 */ static routeOf(raw: string): PushRoute | undefined { const data: PushNotificationData | undefined = parseNotificationData(raw); if (data === undefined) { return undefined; } return { sessionId: data.session_id, mailId: data.mail_id }; } /** * 从 ability 收到的 `want` 里取跳转目标(**带去重**)。 * * 为什么要去重:同一封邮件点两次、或系统重放 want,会让页面重复压栈/重复请求; * `NotificationLedger` 是有界的(不会无限长大)。 */ static routeFromWant(parameters: Record, ledger: NotificationLedger): PushRoute | undefined { const raw = parameters['data']; if (raw === undefined || raw === null) { return undefined; } const text: string = typeof raw === 'string' ? raw : ''; if (text.length === 0) { return undefined; } const route: PushRoute | undefined = PushService.routeOf(text); if (route === undefined) { return undefined; } if (!ledger.shouldHandle(route.mailId)) { return undefined; } return route; } }