跨端: feat(推送客户端): PushService.ets(取 token → 上报 → 点击跳转)+ 接进 EntryAbility ⇒ **文件真的被编译了**
pi 三件里的前两件 + 第三件的"目标解析"这半。 - `pushService.getToken()` → `POST /me/devices/push-token`(`provider/token/device_name/session_id`,省略空项); - 上报标记按 **`accountKey|token`**(服务端同一 token 换账号是**转移**不是并存 ⇒ 标记必须按账号维度,否则新账号永远不报、旧账号的登记已被转走); - 注销:`DELETE` **带 body**(服务端就是这么定的),`deleted:false` 不当错误; - 点击跳转:`onNewWant` → `routeFromWant`(`parseNotificationData` + 有界去重 ledger)→ `PushService.pendingRoute` 等页面来取; - **所有失败静默**(没 token / 401 / 400 / 500 / 权限拒绝 ⇒ 只写 hilog,不提示、不重试、不阻塞启动); - 通知权限**只在拿到 token 后**才申请(没 token 就弹窗是打扰)。 ★ 一条我差点当成"验过了"的证据:`assembleHap` 在**没接入**时也报 `BUILD SUCCESSFUL` —— 因为 ArkTS 只编译**可达**模块,没人 import 的文件根本不参与编译。我植入类型错误后仍是 SUCCESSFUL, 才确认"编译通过"当时**什么都没证明**。接入 EntryAbility 后再植入同样的错误 ⇒ `Failed :entry:default@CompileArkTS` 且指名 `PushService.ets:102` ✓ —— 现在这句"编译通过"是有内容的。 **未完成(不掩盖)**:① 登录成功后主动补报一次(现在只在冷启 `onCreate` 报,冷启时未登录 ⇒ 401 静默,等下次冷启才补上); ② `pendingRoute` 的**消费端**(页面读它并跳转)—— 这两处都要动 `LoginPage/MainPage`,而那两个文件正被别人在飞编辑,我没抢着改。
This commit is contained in:
233
client/harmony/entry/src/main/ets/api/PushService.ets
Normal file
233
client/harmony/entry/src/main/ets/api/PushService.ets
Normal file
@ -0,0 +1,233 @@
|
||||
/*
|
||||
* 推送客户端(平台那半)—— 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,
|
||||
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';
|
||||
|
||||
/** 点击跳转的目标(由通知 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 Push Kit 的 token。**取不到就返回空串**(没装 HMS Core、没登录华为账号、没权限……都是正常情况)。
|
||||
*/
|
||||
private async getToken(): Promise<string> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const token: string = await this.getToken();
|
||||
if (token.length === 0) {
|
||||
return; // 没有 token 就没什么可报的(这不是错误)
|
||||
}
|
||||
// 有 token 才申请通知权限:没 token(没 HMS Core / 没登录华为账号)时**不该弹窗打扰用户**
|
||||
await this.requestEnableNotification();
|
||||
const accountKey: string = this.account.getActiveId();
|
||||
const lastMarker: string = await this.readMarker();
|
||||
/*
|
||||
* ★ 换账号必须重报:服务端里同一 token 换账号是**转移**,
|
||||
* 所以"我报过没有"这个问题的答案**随账号变**(pi `1ce5b03a` 确认的行为)。
|
||||
*/
|
||||
if (!shouldReportToken(lastMarker, accountKey, token)) {
|
||||
return;
|
||||
}
|
||||
const sessionId: string = this.account.getActiveAccount() === null
|
||||
? ''
|
||||
: this.account.getActiveAccount()!.server;
|
||||
const body: PushRegisterBody | undefined =
|
||||
buildTokenBody(PROVIDER_HMS, token, this.deviceName(), sessionId);
|
||||
if (body === undefined) {
|
||||
hilog.info(DOMAIN, TAG, 'body 形状不合法(静默,不发)');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.post<PushRegisterResponse>('/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<void> {
|
||||
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<PushRegisterResponse>('/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<string, Object>, 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;
|
||||
}
|
||||
}
|
||||
@ -16,10 +16,16 @@
|
||||
import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit';
|
||||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||||
import { window } from '@kit.ArkUI';
|
||||
import { ApiClient } from '../api/ApiClient';
|
||||
import { PushService } from '../api/PushService';
|
||||
import { NotificationLedger } from '../model/PushContract';
|
||||
|
||||
const DOMAIN = 0x0000;
|
||||
|
||||
export default class EntryAbility extends UIAbility {
|
||||
/** 通知点击去重(有界) */
|
||||
private ledger: NotificationLedger = new NotificationLedger(50);
|
||||
|
||||
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
try {
|
||||
this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
|
||||
@ -27,6 +33,36 @@ export default class EntryAbility extends UIAbility {
|
||||
hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
|
||||
}
|
||||
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
|
||||
/*
|
||||
* 推送上报:**不 await、失败全静默** —— 启动不能被网络/权限阻塞,也不能因为推送不可用而报错。
|
||||
* 冷启时还没登录 ⇒ POST 会 401,静默忽略;标记没写成功 ⇒ 下次启动会再试一次。
|
||||
*/
|
||||
try {
|
||||
const push: PushService = PushService.getInstance(this.context);
|
||||
push.reportToken(ApiClient.getInstance(this.context)).catch((err: Object) => {
|
||||
hilog.info(DOMAIN, 'testTag', 'push 上报异常(静默):%{public}s', JSON.stringify(err));
|
||||
});
|
||||
} catch (err) {
|
||||
hilog.info(DOMAIN, 'testTag', 'push 初始化跳过(静默):%{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点通知拉起应用时走这里(应用已在运行时)。
|
||||
* 解析不出来就**什么都不做** —— 一条格式不认识的通知,最坏结果应当是"没反应",不是"跳到空页面"。
|
||||
*/
|
||||
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
|
||||
try {
|
||||
const params: Record<string, Object> = want.parameters === undefined
|
||||
? {} as Record<string, Object>
|
||||
: want.parameters as Record<string, Object>;
|
||||
const route = PushService.routeFromWant(params, this.ledger);
|
||||
if (route !== undefined) {
|
||||
PushService.pendingRoute = route; // 页面起来后读它
|
||||
}
|
||||
} catch (err) {
|
||||
hilog.info(DOMAIN, 'testTag', '通知跳转解析失败(静默):%{public}s', JSON.stringify(err));
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(): void {
|
||||
|
||||
Reference in New Issue
Block a user