diff --git a/client/harmony/entry/src/main/ets/api/PushService.ets b/client/harmony/entry/src/main/ets/api/PushService.ets new file mode 100644 index 0000000..f866e84 --- /dev/null +++ b/client/harmony/entry/src/main/ets/api/PushService.ets @@ -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 { + 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 { + 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('/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; + } +} diff --git a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets index 816d220..628c45a 100644 --- a/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/client/harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -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 = want.parameters === undefined + ? {} as Record + : want.parameters as Record; + 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 {