/* * Copyright (c) 2026 Huawei Device Co., Ltd. * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* * `ConfigurationConstant` 本轮**不再需要**:原来为 `setColorMode(COLOR_MODE_NOT_SET)` * 引的,那句已删(见 `onCreate` 里的说明)。应用色彩模式现在统一由 * `MainPage.applyAppearance()` 按用户存下来的 `snap.theme` 决定。 */ import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; /* * ★ `KeyboardAvoidMode` 必须从 `@kit.ArkUI` 引(= `@ohos.arkui.UIContext` 那个)。 * * 全局作用域里**也有**一个同名枚举(`common.d.ts`,只有 DEFAULT/NONE 两个成员), * 它不带 `RESIZE` ⇒ 不引这一条就会报: * Property 'RESIZE' does not exist on type 'typeof KeyboardAvoidMode'. * 两个同名枚举撞在一起,是本条最费时的一步。 */ import { KeyboardAvoidMode, window } from '@kit.ArkUI'; import { BusinessError } from '@kit.BasicServicesKit'; import { ApiClient } from '../api/ApiClient'; import { PushService } from '../api/PushService'; import { NotificationLedger } from '../model/PushContract'; import { Insets, KEY_WINDOW_INSETS, insetsFromAvoidArea } from '../model/WindowInsets'; const DOMAIN = 0x0000; export default class EntryAbility extends UIAbility { /** 通知点击去重(有界) */ private ledger: NotificationLedger = new NotificationLedger(50); onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { /* * ★★ 2026-09-21 **删掉了这里的一句 `setColorMode(COLOR_MODE_NOT_SET)`**。 * * 原来这里是: * this.context.getApplicationContext() * .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); * * 它的语义是"跟随系统",而它是**无条件**执行的 ⇒ 每次冷启都把应用色彩模式 * 重置回跟系统走,**用户在「我的」页选的浅色/深色被丢掉**。 * * 实测症状:选「深色」后按钮显示选中、服务端也存了 dark,但界面仍是浅色 * (背景 rgb(243,243,243) / 文字 rgb(243,243,243),对比度 ≈1:1,不可读)。 * * 为什么会这样:它是目录迁移时抄过来的(`git log -S` 指向 * `f9d757b chore: directory migration`),**没有任何注释说明为什么**。 * * ── 现在由谁负责 ── * * `MainPage.applyAppearance()`:它读本机缓存 + 服务端合并出 `snap.theme`, * 然后调用 `store.applyTheme(ctx, snap.theme)` 真正应用。 * 那里才是"用户偏好的权威"所在。 * * ★ 为什么不在 ability 里也读一次 preferences: * 主题是**按账号**存的(键 `appearance.`,见 `AppearanceStore`), * 而 `onCreate` 时活跃账号可能还没加载出来 ⇒ 会读到错的账号、 * 或者读到空值又退回默认。**一个事实一个权威来源**,这里不重复。 * * ★ 会不会因此丢掉"跟随系统"这条语义:不会 —— * `theme === 'system'` 时 `colorModeValue()` 返回的正是 `COLOR_MODE_NOT_SET`, * 由 `MainPage` 按用户**实际选中的档位**决定,而不是无条件。 */ hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); /* * ★ 2026-09-15:冷启点通知也要跳转。 * 之前只在 onNewWant 解析 want ⇒ 冷启动(进程不在)时点击通知 onCreate 拿到 want 却丢掉跳转目标。 * 这里与 onNewWant 同一付逻辑:解析出 route 就写 pendingRoute,页面起来后读它。 * 解析不出来就不做(一条格式不认识的通知最坏应当是"没反应",不是"跳到空页")。 */ try { const params: Record = want.parameters === undefined ? {} as Record : want.parameters as Record; const route = PushService.routeFromWant(params, this.ledger); if (route !== undefined) { /* * 冷启:页面还没挂载 ⇒ 没人监听 ⇒ 落进格子里,页面起来后由 aboutToAppear 取走。 * 走 deliverRoute 而不是直接写字段,是为了让"两种启动方式"共用一条路径 —— * 见 PushService.deliverRoute 的说明。 */ PushService.deliverRoute(route); } } catch (err) { hilog.info(DOMAIN, 'testTag', '冷启通知跳转解析失败(静默):%{public}s', JSON.stringify(err)); } /* * 推送上报:**不 await、失败全静默** —— 启动不能被网络/权限阻塞,也不能因为推送不可用而报错。 * ★ reportToken 内部先查开关(PushService.isEnabled,默认关):关着就不取 token、 * 不弹权限、不打网关(自部署零开销)。 */ 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) { /* * 热启(应用已在运行):页面**早就挂载完**了,`aboutToAppear` 不会重跑 —— * 所以这里必须**主动叫醒**正在监听的页面,否则"点通知"只会把 App 弹到前台。 * 这是 2026-09-17 模拟器实测出来的:只写字段时,热启路径**完全没有跳转**。 */ PushService.deliverRoute(route); } } catch (err) { hilog.info(DOMAIN, 'testTag', '通知跳转解析失败(静默):%{public}s', JSON.stringify(err)); } } onDestroy(): void { hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); } onWindowStageCreate(windowStage: window.WindowStage): void { // Main window is created, set main page for this ability hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageCreate'); /* * ★ 窗口配置(全屏 + 避让)改到 loadContent 之后 —— 见 setupFullScreenWindow。 * * ── 这里曾经写着「刻意不用 setWindowLayoutFullScreen(true)」并记了一个**错误的结论** ── * 原文:「实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局,于是页签栏被 * 时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)。」 * * 那次实测本身是真的,**结论下错了**:被盖住不是"不该全屏",而是 * **只做了全屏、没做避让**。示例工程(`/tmp/harmonyos-samples-reference`)的 * `WindowUtil` 里,`setWindowLayoutFullScreen` 与 `getWindowAvoidArea` 是 * **同一套东西的两半** —— 少了后一半,前一半当然是灾难。 * * 而那次退回的代价是**黑边一直在**(用户 2026-09-17 报、2026-09-18 又报 * 「你看从头到尾都没修好」)。2026-09-18 实测 1256x2760 四页一致: * 顶部纯黑 136px、底部 60px + 手势条 20px。 * * 现在回到示例工程的做法:全屏 + 读避让 + 布局让位(三处配套,见 setupFullScreenWindow)。 */ windowStage.loadContent('pages/LoginPage', (err) => { if (err.code) { hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); return; } hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); /* * ★ 键盘避让模式改成 `RESIZE`(用户 2026-09-20:「点击回复按键与新建邮件部分 * 的动画与 webui 不一致」→ 连带把回复/转发改成 WebUI 同形的**内联底栏**)。 * * 为什么这一行是**必须**的,而不是可选项: * 官方默认是 `KeyboardAvoidMode.OFFSET` —— 键盘弹起时**整页上移**。 * 内联底栏(回复框 / 转发条)是贴在内容**最底下**的一块, * 页面被推高之后它就出了可视区 ⇒「取消 / 发送 / 转发」点不到。 * * 旧版是覆盖式弹层(`height('60%')`),弹层自己有固定高度, * 键盘弹起时内容能在弹层内部重排 —— 所以那时候靠"弹层有高度 + 说明框 * layoutWeight(1)"绕过去了。**结构一改成内联,那条绕法就失效了**, * 必须正面解决。`harmony-admin` 那条判据当场抓到了这一点。 * * 选 `RESIZE` 而不是 `NONE`:`NONE` 是"不避让",键盘会直接盖住底栏; * `RESIZE` 才是"按剩余高度重排",贴底元素留在屏内。 * (`OFFSET_WITH_CARET`/`RESIZE_WITH_CARET` 是 14+ 的"光标移动也触发"变体, * 我们的底栏只在弹起时关心一次,不需要额外跟随。) */ windowStage.getMainWindowSync().getUIContext().setKeyboardAvoidMode(KeyboardAvoidMode.RESIZE); /* * ★ 在 loadContent **之后**才配窗口(与示例工程同一位置): * 示例 `BaseAbilityHelper.doOnWindowStageCreate` 在 loadContent 回调里调 * `WindowUtil.initialize(windowStage)`。 * 理由:`getUIContext()`(`px2vp` 要用)要有已加载的内容才拿得到。 */ this.setupFullScreenWindow(windowStage); }); } /** * 全屏布局 + 读出避让区 —— 消掉上下黑边的**两半**。 * * ① `setWindowLayoutFullScreen(true)`:内容铺到屏幕四边。**这就是**消黑边的动作。 * ② `getWindowAvoidArea`:读出被状态栏/导航条遮住的高度,写进 AppStorage。 * ③ `MainPage` 根容器把它当 padding 的 top/bottom 用 ⇒ 内容让开时钟/手势区。 * * 少了 ②③,① 会让页签被时钟盖住(这正是上一次退回的原因); * 少了 ①,黑边就一直在(这正是三次报修的原因)。 */ private setupFullScreenWindow(windowStage: window.WindowStage): void { let win: window.Window; try { win = windowStage.getMainWindowSync(); } catch (err) { hilog.error(DOMAIN, 'testTag', '取主窗口失败,全屏/避让配置跳过:%{public}s', JSON.stringify(err)); return; } /* ── ① 全屏 ── */ win.setWindowLayoutFullScreen(true).then(() => { hilog.info(DOMAIN, 'testTag', 'setWindowLayoutFullScreen(true) ok'); }).catch((err: BusinessError) => { hilog.error(DOMAIN, 'testTag', 'setWindowLayoutFullScreen failed: %{public}s', JSON.stringify(err)); }); /* * 状态栏**保留可见**(时间/电量要看得见),只是内容铺到它底下。 * 下面这条 `setWindowSystemBarEnable(['status'])` 是上一位留下的: * 它对状态栏仍有效,且留着不会更糟 —— 但它**不是**消黑边的手段 * (实测没能消掉底部那 60px + 20px)。一并保留,不再靠它。 */ win.setWindowSystemBarEnable(['status']).catch((err: BusinessError) => { hilog.error(DOMAIN, 'testTag', 'setWindowSystemBarEnable failed: %{public}s', JSON.stringify(err)); }); /* ── ② 读避让 + 监听变化 ── */ this.publishInsets(win); try { win.on('avoidAreaChange', () => { this.publishInsets(win); }); } catch (err) { hilog.error(DOMAIN, 'testTag', 'avoidAreaChange 订阅失败:%{public}s', JSON.stringify(err)); } } /** * 读一次避让区并写进 `AppStorage`(键 `KEY_WINDOW_INSETS`)。 * * ★ 换算必须用 `win.getUIContext().px2vp`,**不能**用全局 `px2vp()` —— * 全局那个已被 SDK 标 `@deprecated`,判据 `harmony-system-api` 对全局调用默认判红 * (本仓已踩过这次:`harmony-system-api.test.mjs`)。 */ private publishInsets(win: window.Window): void { try { const system: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM); const navIndicator: window.AvoidArea = win.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR); const uiContext = win.getUIContext(); const next: Insets = insetsFromAvoidArea(system, navIndicator, (px: number) => uiContext.px2vp(px)); AppStorage.setOrCreate(KEY_WINDOW_INSETS, next); hilog.info(DOMAIN, 'testTag', 'insets: statusBar=%{public}d navIndicator=%{public}d', next.statusBar, next.navIndicator); } catch (err) { hilog.error(DOMAIN, 'testTag', '读避让区失败(保持默认 0,黑边照旧):%{public}s', JSON.stringify(err)); } } onWindowStageDestroy(): void { // Main window is destroyed, release UI related resources hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); } onForeground(): void { // Ability has brought to foreground hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); } onBackground(): void { // Ability has back to background hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); } }