From 303db33ea3601ee1f8ff29902794efe73a3ad560 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 21:40:46 +0800 Subject: [PATCH 01/13] test(mobile): add a dark drawer design-preview fixture The drawer preview scenario was light-only, so the drawer's dark rendering could not be captured on a device (the emulator has no system dark-mode switch, and the preview forces the colour mode from the scenario id). Adds immersive-bottom-drawer-dark next to immersive-bottom-drawer and lets the fixture open the drawer for it, so every drawer verification can now be taken in both colour modes. --- .../entry/src/main/ets/entryability/EntryAbility.ets | 4 ++-- .../src/main/ets/pages/preview/ImmersiveBottomPreview.ets | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 4d7629cd9a..167ebcdef1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -25,7 +25,7 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets index ce67b41656..0066fb2a97 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/ImmersiveBottomPreview.ets @@ -275,7 +275,7 @@ export struct ImmersiveBottomPreview { aboutToAppear(): void { const scenarioId = AppStorage.get('scenarioId') ?? ''; this.floating = scenarioId.endsWith('-wide') && !scenarioId.endsWith('-wide-shell'); - this.drawer = scenarioId.endsWith('-drawer') || scenarioId.startsWith('immersive-bottom-tools'); + this.drawer = scenarioId.includes('-drawer') || scenarioId.startsWith('immersive-bottom-tools'); this.settings = scenarioId.endsWith('-settings') || scenarioId.endsWith('-settings-dark'); this.cover = scenarioId.endsWith('-cover'); this.galleryVisible = scenarioId.endsWith('-miniapps'); From 3378dc743e9b4875195c261e7923cde516192d34 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 21:40:47 +0800 Subject: [PATCH 02/13] fix(mobile): rely on the platform-default transparent system bars The window already runs setWindowLayoutFullScreen(true), and the status bar and navigation area are transparent by platform default, carrying the page's own background through. Setting statusBarColor/navigationBarColor explicitly was therefore redundant; the opaque page colour it replaced was what clipped the drawer title and left a white strip over the notification centre. Keeps only the content colours, which is what the official guidance asks a full-screen app to state. Verified pixel-identical against the explicit transparent version on the emulator (status bar strip 240/30 in light/dark, drawer fill 248/14, title ink at y=245). --- .../main/ets/entryability/EntryAbility.ets | 18 ++++++++---------- .../tools/tests/immersive-bottom.test.cjs | 19 ++++++++++++------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 167ebcdef1..2b795d5839 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -132,16 +132,14 @@ export default class EntryAbility extends UIAbility { const content = dark ? MobileDesignColors.ink.dark : MobileDesignColors.ink.light; try { this.mainWindow.setWindowSystemBarProperties({ - // Both bars are transparent, because the window is laid out full-screen - // and the strips they occupy are the page's own edges: the drawer's fill - // and the chat page's blurred header band are what should show there. - // An opaque status bar painted the page colour over both, which clipped - // the sidebar's title, and it also sat over the pull-down notification - // centre's frosted background as a white strip instead of blending into - // it. `statusBarContentColor` is what keeps the clock and the icons - // legible against whatever the page puts behind them. - statusBarColor: MobileDesignColors.transparent.light, - navigationBarColor: MobileDesignColors.transparent.light, + // The window is laid out full-screen above (`setWindowLayoutFullScreen(true)`), + // so it already runs in the immersive layout, and the status bar and the + // navigation area are transparent by platform default: both strips carry + // the page's own background through. The page therefore owns what shows + // there — the drawer's fill and the chat page's blurred header band — and + // any background colour set here would repaint the page's edge instead. + // The content colours are the only thing left to state: they keep the + // clock and the icons legible against whatever the page puts behind them. statusBarContentColor: content, navigationBarContentColor: content }); diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 41c1fa4010..805ed940a3 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -80,19 +80,24 @@ test('the shell runs the window full-screen for every page', () => { 'the welcome page must not switch the window layout any more'); }); -test('both system bars are transparent so the page owns their strips', () => { +test('neither system bar sets a background so the page owns their strips', () => { // The window runs full-screen, so the status bar's strip is the page's own // top edge: the drawer's fill and the chat page's blurred header band are what - // should show there. An opaque bar painted the page colour over both — it + // should show there. The strips are transparent by platform default and carry + // the page's own background through, so the ability must not set a background + // colour on them at all. An opaque bar painted the page colour over both — it // clipped the sidebar's title — and it also sat over the pull-down // notification centre's frosted background as a white strip. The content // colour is what keeps the clock and the icons legible either way. - assert.match(entryAbility, /navigationBarColor: MobileDesignColors\.transparent\.light/, - 'the navigation bar must be transparent so the page bottom shows through'); - assert.match(entryAbility, /statusBarColor: MobileDesignColors\.transparent\.light/, - 'the status bar must be transparent so the page top shows through'); - assert.match(entryAbility, /statusBarContentColor: content/, + const bars = call(normalize(entryAbility), 'setWindowSystemBarProperties'); + assert.equal(bars.includes('statusBarColor:'), false, + 'the status bar must keep the platform default background instead of setting one'); + assert.equal(bars.includes('navigationBarColor:'), false, + 'the navigation bar must keep the platform default background instead of setting one'); + assert.match(bars, /statusBarContentColor: content/, 'the status bar icons must keep following the colour mode'); + assert.match(bars, /navigationBarContentColor: content/, + 'the navigation bar icons must keep following the colour mode'); }); test('the window inset service is the only place the strip is measured', () => { From 3e31917988996a19402d02c83ea8c0eef6b453e7 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 21:57:12 +0800 Subject: [PATCH 03/13] feat(mobile): treat the camera cutout as an avoid area The camera cutout is not one of the framework's avoid areas until the module declares `avoid_cutout` (API 12+), so nothing in this app could see it at all: the tree had no CUTOUT reference, and the immersive shell installs the page under the whole window. On a rotated device the cutout moves onto a side edge, where it lands on the drawer panel, the home headers and the composer row - the layouts this app already treats as first-class targets. Declaring the metadata is only what makes the cutout reportable; the padding that consumes it is the next change. The launch log now states the whole area - visibility plus all four rectangles - because it is the one avoid area whose edge changes with rotation, and a device without a cutout has to say so in the evidence rather than stay silent about it. Measured on the bundled phone emulator (1256x2760 px window): the area reports visible=false top=74 bottom=0 left=0 right=0 both with and without the metadata, so this emulator has no cutout to exercise the behaviour with. --- .../src/main/ets/entryability/EntryAbility.ets | 10 +++++++++- .../mobile/harmonyos/entry/src/main/module.json5 | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 2b795d5839..a8436edc13 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -109,7 +109,10 @@ export default class EntryAbility extends UIAbility { /** * The immersive shell is not verifiable from a screenshot alone: whether the * window covers the status bar and how tall the bottom strip is are device - * facts, so state them once per launch. + * facts, so state them once per launch. The camera cutout is stated in full — + * visibility and all four rectangles — because it is the one avoid area that + * moves to a different edge when the device rotates, and a device without a + * cutout reports zeroes here rather than staying silent about it. */ private logWindowInsets(mainWindow: window.Window): void { try { @@ -119,6 +122,11 @@ export default class EntryAbility extends UIAbility { 'Window insets statusBar=%{public}s navigation=%{public}s windowRect=%{public}s', String(system.topRect.height), String(navigation.bottomRect.height), JSON.stringify(mainWindow.getWindowProperties().windowRect)); + const cutout = mainWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_CUTOUT); + hilog.info(DOMAIN, TAG, + 'Window cutout visible=%{public}s top=%{public}s bottom=%{public}s left=%{public}s right=%{public}s', + String(cutout.visible), String(cutout.topRect.height), String(cutout.bottomRect.height), + String(cutout.leftRect.width), String(cutout.rightRect.width)); } catch (err) { hilog.error(DOMAIN, TAG, 'Failed to read window insets. Cause: %{public}s', JSON.stringify(err)); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/module.json5 b/src/apps/mobile/harmonyos/entry/src/main/module.json5 index b52c821f61..6e5460c280 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/module.json5 +++ b/src/apps/mobile/harmonyos/entry/src/main/module.json5 @@ -10,6 +10,19 @@ ], "deliveryWithInstall": true, "installationFree": false, + // The camera cutout is not an avoid area by default, so a page does not stay + // clear of it — and in landscape it sits on a side edge of a full-width + // band. From API version 12 on, a module makes the cutout a default avoid + // area with this metadata, which is what lets the window report it through + // `getWindowAvoidArea(TYPE_CUTOUT)` and lets a component expand into it + // (`ts-universal-attributes-expand-safe-area`; module-level `metadata` is + // legal per `module-configuration-file`). + "metadata": [ + { + "name": "avoid_cutout", + "value": "true" + } + ], "requestPermissions": [ { "name": "ohos.permission.INTERNET" From eb7a210a6f167fe37222175731a788d20af754d3 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 21:57:20 +0800 Subject: [PATCH 04/13] feat(mobile): read the window insets on all four edges WindowInsets carried top and bottom only, so even a reported cutout had nowhere to land: the binding could not express the side it rotated onto. More than one avoid area claims an edge - the status bar and a landscape cutout both touch the top, the navigation indicator and a reverse cutout both touch the bottom - and they are overlapping regions of one window rather than strips stacked on each other, so an edge is the widest claim among them and never their sum. That is the merge rule the official handleCutoutAvoidArea sample performs, so it is extracted as a pure function instead of being inlined in the reader: the per-edge maximum is then assertable without a window, and `insetsOf` keeps only the reading and the keyboard rule (which still takes the bottom strip while the keyboard is up, because KeyboardAvoidMode.RESIZE has already lifted the page above it). The binding publishes left/right and three padding primitives. Left and right are per side because a cutout only ever lands on one of them: a full-width band must keep the design's own gutter on the side the device does not claim, while `sidePadding` reserves the wider strip on both sides for a control that has to stay on the window's centre line. --- .../ets/services/WindowSystemBarService.ets | 127 +++++++++++++++--- 1 file changed, 109 insertions(+), 18 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index ac9c0c684c..06846524f9 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -2,17 +2,67 @@ import { Context } from '@kit.AbilityKit'; import { UIContext, window } from '@kit.ArkUI'; import { RemoteLogger } from './RemoteLogger'; +/** + * The strip one avoid area claims along each edge of the window, in px. + */ +export interface AvoidEdges { + top: number; + bottom: number; + left: number; + right: number; +} + /** * How much of the window the app has to keep clear of its own content, in px. * - * Both numbers are read rather than assumed. The shell runs in immersive layout, - * so the page draws from the top of the window and the status bar is the app's - * own inset to reserve; the bottom strip is the navigation indicator, until the - * soft keyboard takes its place. + * Every number is read rather than assumed. The shell runs in immersive layout, + * so the page draws from the edges of the window: the status bar is the app's + * own top inset to reserve, the bottom strip is the navigation indicator until + * the soft keyboard takes its place, and the sides are the camera cutout once + * the device has rotated it onto one of them. */ export interface WindowInsets { top: number; bottom: number; + left: number; + right: number; +} + +/** The four edge widths one avoid area reports, in px. */ +export function avoidEdgesOf(area: window.AvoidArea): AvoidEdges { + return { + top: area.topRect.height, + bottom: area.bottomRect.height, + left: area.leftRect.width, + right: area.rightRect.width + }; +} + +/** + * Merges the avoid areas that share an edge into the strip that edge needs. + * + * More than one area can claim the same edge — the status bar and a landscape + * camera cutout both touch the top, the navigation indicator and a reverse + * cutout both touch the bottom — and they are overlapping regions of one window + * rather than strips stacked on top of each other, so an edge is the widest + * claim among them and never their sum. The official `handleCutoutAvoidArea` + * sample merges the same way, one `Math.max` per edge. + * + * Pure, four numbers in and four numbers out, so the merge is assertable + * without a window; `insetsOf` keeps only the reading and the keyboard rule. + */ +export function mergeAvoidEdges(areas: AvoidEdges[]): AvoidEdges { + let top = 0; + let bottom = 0; + let left = 0; + let right = 0; + for (const area of areas) { + top = Math.max(top, area.top); + bottom = Math.max(bottom, area.bottom); + left = Math.max(left, area.left); + right = Math.max(right, area.right); + } + return { top: top, bottom: bottom, left: left, right: right }; } /** One read of the insets, plus the listener that keeps them current. */ @@ -70,27 +120,36 @@ export class WindowSystemBarService { read(); return subscription; } catch (_err) { - onChange({ top: 0, bottom: 0 }); + onChange({ top: 0, bottom: 0, left: 0, right: 0 }); return (): void => {}; } } + private static avoidEdges(appWindow: window.Window, type: window.AvoidAreaType): AvoidEdges { + return avoidEdgesOf(appWindow.getWindowAvoidArea(type)); + } + private static insetsOf(appWindow: window.Window): WindowInsets { - const top = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height; + // The camera cutout is an avoid area of its own, and only because the module + // declares `avoid_cutout` (see module.json5). Rotating the device moves it + // from the top edge to a side one, which is why it is merged here with the + // system and navigation areas instead of being left to the top strip. + const merged = mergeAvoidEdges([ + WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_SYSTEM), + WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR), + WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_CUTOUT) + ]); // The keyboard covers the navigation indicator while it is up, and // `KeyboardAvoidMode.RESIZE` has already lifted the page above it, so the // bottom strip belongs to the keyboard for as long as it is visible. - const keyboard = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_KEYBOARD).bottomRect.height; - if (keyboard > 0) { - return { top, bottom: 0 }; - } - const navigation = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR).bottomRect.height; - if (navigation > 0) { - return { top, bottom: navigation }; - } - // Some builds fold the navigation bar into the system avoid area instead of - // reporting an indicator of its own. - return { top, bottom: appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).bottomRect.height }; + const keyboardVisible = + appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_KEYBOARD).bottomRect.height > 0; + return { + top: merged.top, + bottom: keyboardVisible ? 0 : merged.bottom, + left: merged.left, + right: merged.right + }; } } @@ -101,6 +160,9 @@ export class WindowSystemBarService { * edge reads these numbers instead of assuming the framework reserved the strip * for it: the chat page's fade fills the bottom edge, and the interactive rows * of every other page — drawers, sheets, covers, galleries — keep clear of it. + * The sides are part of the same model: they are the camera cutout, which a + * rotated device moves onto one of them, so a full-width band reserves them + * before its own design spacing rather than assuming the page starts clear. * They mount and unmount independently, so the binding owns the subscription * rather than leaving each page to juggle one, and it holds vp because padding, * margins and radii in this app are vp. @@ -109,6 +171,8 @@ export class WindowSystemBarService { export class WindowInsetsBinding { @Trace top: number = 0; @Trace bottom: number = 0; + @Trace left: number = 0; + @Trace right: number = 0; private subscription?: WindowInsetSubscription; private disposed: boolean = false; @@ -120,7 +184,9 @@ export class WindowInsetsBinding { } this.top = uiContext.px2vp(insets.top); this.bottom = uiContext.px2vp(insets.bottom); - RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom}`); + this.left = uiContext.px2vp(insets.left); + this.right = uiContext.px2vp(insets.right); + RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom} left=${this.left} right=${this.right}`); }).then((subscription: WindowInsetSubscription): void => { // The page can disappear before the window call resolves; releasing here // keeps a listener from outliving the component that asked for it. @@ -149,6 +215,31 @@ export class WindowInsetsBinding { return Math.max(designSpacing, this.bottom); } + /** + * The padding a side-anchored content layer needs. Split by side rather than + * exposed as one pair because a cutout only ever lands on one of them: the + * left strip stays 0 while the right one carries the camera, and padding both + * by the wider value would push the content of a full-width band off-centre. + */ + leftPadding(designSpacing: number): number { + return Math.max(designSpacing, this.left); + } + + rightPadding(designSpacing: number): number { + return Math.max(designSpacing, this.right); + } + + /** + * The clearance both sides need for a control that has to stay centred — a + * glyph, a pill, an icon whose own box is what the user sees. Reserving the + * wider strip on both sides is what keeps it on the window's centre line; a + * full-width band wants `leftPadding`/`rightPadding` instead, so its own + * design spacing is only ever raised on the side the device actually claims. + */ + sidePadding(designSpacing: number): number { + return Math.max(designSpacing, Math.max(this.left, this.right)); + } + /** * The tail spacer a scrolling surface appends after its last row: the strip * itself plus the design's own breathing. The viewport then runs to the From 3e39f8ef0520a83518e50af1d8eff79fe4161d91 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 21:57:35 +0800 Subject: [PATCH 05/13] feat(mobile): reserve the side strips wherever a surface owns a side edge A surface whose side edge is the window's edge is what the cutout lands on once the device rotates, so every one of them now consumes the side insets through `leftPadding`/`rightPadding` instead of writing a bare design value: the drawer panel root (which keeps its 20), the compact and wide home headers, the mini-app gallery header, the file preview header, the chat page's header band and composer layer, and the floating master-restore control's x offset. Portrait reports left=right=0, so `max(design, strip)` is exactly the design value there and the geometry is unchanged - the change only exists on a device that rotated its camera onto that side. The audit of the remaining surfaces says the sheets and covers need none of it: bindSheet and bindContentCover inset their own page horizontally, so their rows never reach the window's side edges and a side padding there would only move content that was already clear. That is why SettingsSheet, WorkspaceToolsPanel, SidebarWorkspacePicker, SessionDetailsView, ConversationViewSettings, ConnectView and RemoteSessionList are left alone. The four full-bleed backgrounds - the drawer floor, the account cover's scrim, the chat page's bottom fade and the home dock - also claim SafeAreaType.CUTOUT now. The framework defines SYSTEM as the status bar and the navigation bar only, so a fill that claims the system area alone stops short of a notch and shows the page colour beside it. Their edge lists are unchanged: only the region class the design already declared is widened. The contract test carried the old shapes as exact strings and is updated with them, plus the new four-direction contract: the merge must read TYPE_CUTOUT, the binding must expose left/right and the three paddings, every fill must claim the cutout, the module must declare avoid_cutout, and the edge surfaces must consume the side insets through the binding. --- .../main/ets/pages/components/AppShell.ets | 9 +- .../main/ets/pages/components/AppSidebar.ets | 7 +- .../ets/pages/components/ConversationView.ets | 23 ++- .../pages/components/FilePreviewSurface.ets | 11 +- .../ets/pages/components/MiniAppSurface.ets | 7 +- .../main/ets/pages/components/WelcomeHome.ets | 4 +- .../pages/components/WideConversationHost.ets | 6 +- .../components/remote/RemoteSurfaceHost.ets | 20 ++- .../tools/tests/immersive-bottom.test.cjs | 152 ++++++++++++++++-- 9 files changed, 205 insertions(+), 34 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index c0436728b0..e728315e2f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -58,7 +58,10 @@ export struct AppShell { .width('100%') .height('100%') .backgroundColor(SIDEBAR_BG) - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + // The cutout is an avoid area of its own, so the floor claims it too: a + // notch the framework would otherwise leave bare has to end up under the + // same fill the rest of the window is. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .hitTestBehavior(HitTestMode.None) .opacity((this.allowSidebar && this.shellState.showSidebar) ? 1 : 0) // Matched to the content card rather than to the sidebar panel: the @@ -148,7 +151,9 @@ export struct AppShell { private CompactLoginCover() { Stack({ alignContent: Alignment.Bottom }) { Column().width('100%').height('100%').backgroundColor(SCRIM) - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + // The scrim is the cover's own background over the whole shell, so a bare + // notch would read as a hole in the dimming: it claims the cutout too. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .onClick(() => { this.shellState.showSettings = false; }) Scroll() { Column() { this.settings() } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets index 911d90b97c..cf87d8971d 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppSidebar.ets @@ -163,7 +163,12 @@ export struct AppSidebar { // row is what has to keep clear of the status bar: the fill still reaches // the top of the window, and the title, the search field and the session // rows start below the strip. - .padding({ left: 20, right: 20, top: this.insets.top }) + // + // Its side edges are the screen's too, and a rotated device moves the camera + // cutout onto one of them: the wider of the design's gutter and the cutout + // is what the panel reserves, so the 20 the design asks for survives on a + // device that claims no side strip at all. + .padding({ left: this.insets.leftPadding(20), right: this.insets.rightPadding(20), top: this.insets.top }) .backgroundColor(SIDEBAR_BG) } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 7df81a5de3..dec1828440 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -184,8 +184,10 @@ export struct ConversationView { .width('100%') // Carried by the band that owns the background, not by the wrapper: the // band is what has to reach the status bar, and its measured height is - // what the transcript borrows as its content start offset. - .padding({ top: this.topSafeInset }) + // what the transcript borrows as its content start offset. The band is the + // window's full width too, so it reserves the side strips as well; the + // header's own gutter lives inside it and is not restated here. + .padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), top: this.topSafeInset }) .backgroundColor(PAGE_BG_OVERLAY) .backgroundBlurStyle(BlurStyle.Thin) this.TopEdgeFade() @@ -206,8 +208,15 @@ export struct ConversationView { // The composer keeps the distance from the screen edge it had while the page // area ended above the navigation bar, and the layer's own box grows by that // strip: the gradient below is the layer's background, so it fills the strip - // and the transcript behind it reaches the bottom of the screen. - .padding({ bottom: this.bottomSafeInset }) + // and the transcript behind it reaches the bottom of the screen. The layer + // spans the window's width, so the side strips are the layer's own business + // as well: the composer's gutter is inside it, and on a rotated device the + // camera cutout would otherwise sit over the first control of the row. + .padding({ + left: this.insets.leftPadding(0), + right: this.insets.rightPadding(0), + bottom: this.bottomSafeInset + }) // The fade covers the whole layer, not just a strip above it: the transcript // runs behind the composer, so anything short of that leaves a line of text // sitting crisp and legible beside the pill after it has already faded out @@ -221,8 +230,10 @@ export struct ConversationView { // into it. Where the immersive layout already puts the page area there the // two overlap and nothing extra is drawn; on a window that still ends above // the navigation bar this is what keeps the strip from being bare page - // colour under the fade. - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) + // colour under the fade. The cutout is a separate avoid area, so the fade + // claims it as well: a device that rotated its camera onto an edge the fade + // reaches would otherwise show the page colour beside the gradient. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.BOTTOM]) // Named so a layout dump can be read against the pixels: the fade's own box // is what says where the chat page's bottom edge sits and how far the // transcript can scroll, and neither is visible in the composer's bounds. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets index d225c3d762..88eb84c805 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -180,8 +180,15 @@ export struct FilePreviewSurface { // The header band is the surface's top edge in both hosts — the compact // full-page overlay and the wide preview pane — so it carries the status bar // strip itself: the band's fill still reaches the top of the window and the - // back control and title sit below the clock and the indicators. - .padding({ left: 8, right: 8, top: 8 + this.insets.top, bottom: 8 }) + // back control and title sit below the clock and the indicators. It spans + // the window horizontally, so it carries the side strips the same way: on a + // rotated device the back control is what the camera cutout would cover. + .padding({ + left: this.insets.leftPadding(8), + right: this.insets.rightPadding(8), + top: 8 + this.insets.top, + bottom: 8 + }) .alignItems(VerticalAlign.Center) .border({ width: { bottom: 1 }, color: LINE }) .backgroundColor(PAGE_BG) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets index 945a403141..65dc7d9f44 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -30,7 +30,9 @@ export struct MiniAppSurface { // The gallery is a full-screen surface, so its header band owns the top // edge of the window: the box grows by the status bar strip and the // controls keep the 56 the design gives them below it, instead of sitting - // under the clock and the indicators. + // under the clock and the indicators. The band spans the window + // horizontally, so it reserves the side strips too — a rotated device puts + // the camera cutout on the back control's edge. Row({ space: 8 }) { NavigationBackButton({ onBack: (): void => { if (this.state.appId) this.actions.back(); @@ -41,7 +43,8 @@ export struct MiniAppSurface { .fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center).layoutWeight(1) Blank().width(48) - }.width('100%').height(56 + this.insets.top).padding({ left: 12, right: 12, top: this.insets.top }) + }.width('100%').height(56 + this.insets.top) + .padding({ left: this.insets.leftPadding(12), right: this.insets.rightPadding(12), top: this.insets.top }) if (this.state.document) { MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request }) .layoutWeight(1) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index 61fa36977f..120e733a84 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -167,7 +167,9 @@ export struct WelcomeHome { .padding({ left: G.welcomeGutter, right: G.welcomeGutter, top: this.wide() ? 0 : G.welcomeGutter, bottom: this.wide() ? 0 : this.insets.bottomPadding(G.welcomeDockBottom) }) .backgroundColor(this.wide() ? TRANSPARENT : $r('app.color.welcome_dock')) - .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) + // The dock's fill reaches the bottom edge of the screen. The cutout is a + // separate avoid area from the system bars, so it is claimed here too. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.BOTTOM]) .borderRadius({ topLeft: G.welcomeDockRadius, topRight: G.welcomeDockRadius }) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index e334010441..9f248a3ce8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -136,9 +136,11 @@ export struct WideConversationHost { if (this.wideMasterPaneCollapsed) { // The detail pane's top edge is the window's, so the floating control - // keeps the status bar strip above it instead of sitting under it. + // keeps the status bar strip above it instead of sitting under it — and + // its left edge is the window's too, which is the edge a rotated device + // moves the camera cutout onto. SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) - .position({ x: 12, y: 12 + this.insets.top }).zIndex(2) + .position({ x: this.insets.leftPadding(12), y: 12 + this.insets.top }).zIndex(2) .hitTestBehavior(HitTestMode.Default) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index dff71d963e..897f81ced4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -237,7 +237,9 @@ export struct RemoteSurfaceHost { Column() { // The compact home is a page, not a sheet: its header band is the top edge // of the window, so the band reserves the status bar and the drawer - // control and title start below the clock and the indicators. + // control and title start below the clock and the indicators. The band is + // the window's full width, so it reserves the side strips as well — the + // drawer control is the control a landscape cutout would land on. ConversationHeader({ title: RemoteI18n.t('app.title'), subtitle: this.compactHeaderContext(), @@ -245,7 +247,11 @@ export struct RemoteSurfaceHost { showSidebarButton: true, onOpenSidebar: this.onOpenSidebar }) - .padding({ top: this.insets.top }) + .padding({ + left: this.insets.leftPadding(0), + right: this.insets.rightPadding(0), + top: this.insets.top + }) if (RemoteCompactHomePolicy.shouldShowConnectHome(this.remotePageState.connectionState)) { this.ConnectionEntry() } else { @@ -292,9 +298,15 @@ export struct RemoteSurfaceHost { // page, not a sheet, so it should not grow a divider just because it is // wider. The band's box grows by the status bar strip for the same reason // the compact header's does: the wide detail pane's top edge is the - // window's top edge too. + // window's top edge too, and its side edge is the window's as well, so the + // restore control keeps clear of a cutout that rotated onto that side. .width('100%').height(76 + this.insets.top) - .padding({ left: 16, right: 16, top: 14 + this.insets.top, bottom: 12 }) + .padding({ + left: this.insets.leftPadding(16), + right: this.insets.rightPadding(16), + top: 14 + this.insets.top, + bottom: 12 + }) this.CompactEmptyState() diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 805ed940a3..8752d17130 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -18,6 +18,8 @@ function source(relativePath) { const entryAbility = source('entry/src/main/ets/entryability/EntryAbility.ets'); const conversationView = source('entry/src/main/ets/pages/components/ConversationView.ets'); const windowService = source('entry/src/main/ets/services/WindowSystemBarService.ets'); +const moduleConfig = source('entry/src/main/module.json5'); +const appShell = source('entry/src/main/ets/pages/components/AppShell.ets'); const appSidebar = source('entry/src/main/ets/pages/components/AppSidebar.ets'); const miniAppSurface = source('entry/src/main/ets/pages/components/MiniAppSurface.ets'); const settingsSheet = source('entry/src/main/ets/pages/components/SettingsSheet.ets'); @@ -109,6 +111,70 @@ test('the window inset service is the only place the strip is measured', () => { assert.match(windowService, /appWindow\.off\('avoidAreaChange'/, 'the binding must release its listener'); }); +test('the insets merge every avoid area that claims an edge, cutout included', () => { + // The camera cutout is an avoid area of its own: the framework's SYSTEM area + // is the status bar and the navigation bar only, so a model that read those + // two could not express a cutout at all — and the cutout is the one area that + // moves, onto a side edge, as soon as the device rotates. More than one area + // can claim the same edge, and they overlap rather than stack, so an edge is + // the widest claim among them: the merge is one `Math.max` per edge, the same + // merge the official `handleCutoutAvoidArea` sample performs. + assert.match(windowService, /export function mergeAvoidEdges\(areas: AvoidEdges\[\]\): AvoidEdges/, + 'the per-edge merge must stay a function of its own, not be buried in the reader'); + const merge = normalize(windowService.slice( + windowService.indexOf('export function mergeAvoidEdges'), + windowService.indexOf('export class WindowSystemBarService'))); + for (const edge of ['top', 'bottom', 'left', 'right']) { + assert.match(merge, new RegExp(`${edge} = Math\\.max\\(${edge}, area\\.${edge}\\)`), + `the merge must take the widest claim on the ${edge} edge`); + } + const insetsOf = normalize(windowService.slice(windowService.indexOf('private static insetsOf'))); + for (const type of ['TYPE_SYSTEM', 'TYPE_NAVIGATION_INDICATOR', 'TYPE_CUTOUT']) { + assert.match(insetsOf, new RegExp(type), + `${type} must be one of the areas the insets are read from`); + } + // The keyboard keeps owning the bottom strip while it is up: `RESIZE` has + // already lifted the page above it (see ConversationView), so reserving the + // navigation indicator as well would count the same strip twice. + assert.match(insetsOf, /bottom: keyboardVisible \? 0 : merged\.bottom/, + 'the keyboard must keep taking the bottom strip while it is visible'); +}); + +test('the binding exposes the side strips and the paddings that consume them', () => { + // Left and right are 0 on a portrait phone, so `max(design, strip)` is exactly + // the design value there and the change is invisible until a device rotates + // its camera onto that side. The three primitives are there because a cutout + // only ever lands on one side: a full-width band reserves the side the device + // claims and keeps its own gutter on the other, while a control that has to + // stay centred reserves the wider of the two on both. + for (const edge of ['top', 'bottom', 'left', 'right']) { + assert.match(windowService, new RegExp(`@Trace ${edge}: number = 0;`), + `the binding must publish the ${edge} strip`); + assert.match(windowService, new RegExp(`this\\.${edge} = uiContext\\.px2vp\\(insets\\.${edge}\\)`), + `the binding must convert the ${edge} strip to vp like the others`); + } + assert.match(normalize(windowService), + /leftPadding\(designSpacing: number\): number \{ return Math\.max\(designSpacing, this\.left\); \}/, + 'a side strip must only ever raise its own side above the design spacing'); + assert.match(normalize(windowService), + /rightPadding\(designSpacing: number\): number \{ return Math\.max\(designSpacing, this\.right\); \}/, + 'a side strip must only ever raise its own side above the design spacing'); + assert.match(normalize(windowService), + /sidePadding\(designSpacing: number\): number \{ return Math\.max\(designSpacing, Math\.max\(this\.left, this\.right\)\); \}/, + 'a control that stays centred reserves the wider of the two sides'); +}); + +test('the module declares the cutout as an avoid area', () => { + // Without this metadata the page does not avoid the camera cutout at all: the + // framework only treats it as an avoid area once the module asks, so neither + // the side strips below nor a background expanded into it would exist. It is a + // module-level entry, not a per-ability one: the declaration has to sit before + // the abilities array starts. + const moduleBlock = moduleConfig.slice(moduleConfig.indexOf('"module"'), moduleConfig.lastIndexOf('"abilities"')); + assert.match(moduleBlock, /"name": "avoid_cutout",[\s\S]*?"value": "true"/, + 'the module must declare avoid_cutout before its abilities'); +}); + test('the binding owns both strip numbers a surface needs', () => { // bottomPadding is for a fixed control: the design's spacing, or the strip if // the strip is larger. tailSpacing is for the end of a scrolling surface: the @@ -121,12 +187,15 @@ test('the binding owns both strip numbers a surface needs', () => { test('the chat page bottom layer reaches the screen edge and its composer does not move', () => { const bottom = builderBody(conversationView, 'BottomOverlay'); - // The layer's box grows by the strip, so its own gradient fills it... - assert.equal(call(bottom, 'padding'), '.padding({ bottom: this.bottomSafeInset })'); + // The layer's box grows by the strip, so its own gradient fills it — and it + // carries the side strips too, because the layer is the window's full width + // and the composer's own gutter lives inside it... + assert.equal(call(bottom, 'padding'), + '.padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), bottom: this.bottomSafeInset })'); // ...it may paint into the strip even where the page area still stops above // it, and it is named so a layout dump can be read against the pixels. assert.equal(call(bottom, 'expandSafeArea'), - '.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])'); + '.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.BOTTOM])'); assert.equal(call(bottom, 'id'), ".id('conversation-bottom-fade')"); // The transcript borrows the layer's measured height, which is what lets the // last message scroll above the fade instead of under the composer. @@ -137,9 +206,13 @@ test('the chat page bottom layer reaches the screen edge and its composer does n test('the chat page header band reserves the status bar itself', () => { // The shell is immersive, so the page draws from the top of the window and the // band is what has to keep the header out of the status bar. Its own box is - // the transcript's content start offset, so the two stay in step. + // the transcript's content start offset, so the two stay in step. The band is + // the window's full width as well, which is what makes its sides the sides the + // camera cutout rotates onto; the header's own gutter is inside it and is not + // restated here. const top = builderBody(conversationView, 'TopOverlay'); - assert.equal(call(top, 'padding'), '.padding({ top: this.topSafeInset })'); + assert.equal(call(top, 'padding'), + '.padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), top: this.topSafeInset })'); assert.match(top, /\.backgroundColor\(PAGE_BG_OVERLAY\)/, 'the band that carries the inset must stay the one that paints the header'); }); @@ -153,25 +226,26 @@ test('every surface whose top edge is the screen edge reserves the status bar', // back control, the preview's header and the home headers' drawer control are // all one strip lower than they were while the page area ended below it. assert.match(normalize(builderBody(appSidebar, 'SidebarContent')), - /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/, + /\.padding\(\{ left: this\.insets\.leftPadding\(20\), right: this\.insets\.rightPadding\(20\), top: this\.insets\.top \}\)/, 'the drawer panel must reserve the strip above its title'); assert.match(normalize(miniAppSurface), - /\.height\(56 \+ this\.insets\.top\)\.padding\(\{ left: 12, right: 12, top: this\.insets\.top \}\)/, + /\.height\(56 \+ this\.insets\.top\)\s*\.padding\(\{ left: this\.insets\.leftPadding\(12\), right: this\.insets\.rightPadding\(12\), top: this\.insets\.top \}\)/, 'the mini-app gallery header must reserve the strip'); assert.match(normalize(filePreviewSurface), - /\.height\(68 \+ this\.insets\.top\)[\s\S]*?\.padding\(\{ left: 8, right: 8, top: 8 \+ this\.insets\.top, bottom: 8 \}\)/, + /\.height\(68 \+ this\.insets\.top\)[\s\S]*?\.padding\(\{ left: this\.insets\.leftPadding\(8\), right: this\.insets\.rightPadding\(8\), top: 8 \+ this\.insets\.top, bottom: 8 \}\)/, 'the file preview header must reserve the strip'); assert.match(normalize(remoteSurfaceHost), - /\.padding\(\{ top: this\.insets\.top \}\)/, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), top: this\.insets\.top \}\)/, 'the compact home header must reserve the strip'); assert.match(normalize(remoteSurfaceHost), - /\.height\(76 \+ this\.insets\.top\) \.padding\(\{ left: 16, right: 16, top: 14 \+ this\.insets\.top, bottom: 12 \}\)/, + /\.height\(76 \+ this\.insets\.top\)\s*\.padding\(\{ left: this\.insets\.leftPadding\(16\), right: this\.insets\.rightPadding\(16\), top: 14 \+ this\.insets\.top, bottom: 12 \}\)/, 'the wide home header must reserve the strip'); // A floating control on a pane whose top edge is the window's keeps the strip // clear itself, the way the surfaces' fixed bottom controls keep the - // navigation bar clear. + // navigation bar clear. Its left edge is the window's edge in the collapsed + // wide layout, so the same offset carries the side strip. assert.match(normalize(wideConversationHost), - /\.position\(\{ x: 12, y: 12 \+ this\.insets\.top \}\)/, + /\.position\(\{ x: this\.insets\.leftPadding\(12\), y: 12 \+ this\.insets\.top \}\)/, 'the floating master-restore control must reserve the strip'); for (const [name, text] of [['RemoteSurfaceHost', remoteSurfaceHost], ['WideConversationHost', wideConversationHost]]) { @@ -223,7 +297,8 @@ test('the sidebar list scrolls under the bar and its floating footer keeps the s // the footer keeps its distance from that edge itself, and the list ends in // a tail spacer so its last row rests above the navigation bar. const content = normalize(builderBody(appSidebar, 'SidebarContent')); - assert.match(content, /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/, + assert.match(content, + /\.padding\(\{ left: this\.insets\.leftPadding\(20\), right: this\.insets\.rightPadding\(20\), top: this\.insets\.top \}\)/, 'the panel root must reserve the status bar without shrinking its scrolling viewport'); assert.match(content, /\.padding\(\{ bottom: this\.scrollTailPadding\(\) \}\)/, 'the session list must end in a tail spacer'); @@ -340,10 +415,59 @@ test('the welcome dock reads the strip through the shared binding', () => { /bottom: this\.wide\(\) \? 0 : this\.insets\.bottomPadding\(G\.welcomeDockBottom\)/, 'the dock must lift its content by the strip wherever the indicator is taller'); assert.match(welcomeHome, - /\.expandSafeArea\(\[SafeAreaType\.SYSTEM\], \[SafeAreaEdge\.BOTTOM\]\)/, + /\.expandSafeArea\(\[SafeAreaType\.SYSTEM, SafeAreaType\.CUTOUT\], \[SafeAreaEdge\.BOTTOM\]\)/, 'the dock fill must still reach the screen edge'); }); +test('every background that owns a screen edge claims the cutout as well', () => { + // The CUTOUT area is described separately from SYSTEM — which the framework + // defines as the status bar and the navigation bar — so a fill that claims the + // system area alone stops short of a notch and shows the page colour beside + // it. These are the four full-bleed backgrounds of the shell: the drawer's + // floor, the account cover's scrim, the chat page's bottom fade and the home + // dock. + for (const [name, text, expected] of [['AppShell', appShell, 2], + ['ConversationView', conversationView, 1], ['WelcomeHome', welcomeHome, 1]]) { + const claims = (text.match(/\.expandSafeArea\(\[SafeAreaType\.SYSTEM, SafeAreaType\.CUTOUT\]/g) ?? []).length; + assert.equal(claims, expected, `${name} must claim the cutout wherever it claims the system area`); + const systemOnly = (text.match(/\.expandSafeArea\(\[SafeAreaType\.SYSTEM\]/g) ?? []).length; + assert.equal(systemOnly, 0, `${name} must not leave a fill on the system area alone`); + } +}); + +test('every surface whose side edge is the screen edge reserves the side strips', () => { + // The side strips are the camera cutout, which a rotated device moves onto the + // window's left or right edge. A full-width band consumes them per side — + // `max(design, strip)` — so the design's own gutter survives on a device that + // claims no side strip at all, which is every portrait phone. + // + // The sheets and covers are deliberately absent: `bindSheet` and + // `bindContentCover` inset their own page horizontally, so their rows never + // reach the window's side edges and a side padding there would only move + // content that was already clear. + for (const [name, text, pattern] of [ + ['the drawer panel', appSidebar, + /\.padding\(\{ left: this\.insets\.leftPadding\(20\), right: this\.insets\.rightPadding\(20\), top: this\.insets\.top \}\)/], + ['the compact home header', remoteSurfaceHost, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), top: this\.insets\.top \}\)/], + ['the wide home header', remoteSurfaceHost, + /\.padding\(\{ left: this\.insets\.leftPadding\(16\), right: this\.insets\.rightPadding\(16\), top: 14 \+ this\.insets\.top, bottom: 12 \}\)/], + ['the gallery header', miniAppSurface, + /\.padding\(\{ left: this\.insets\.leftPadding\(12\), right: this\.insets\.rightPadding\(12\), top: this\.insets\.top \}\)/], + ['the preview header', filePreviewSurface, + /\.padding\(\{ left: this\.insets\.leftPadding\(8\), right: this\.insets\.rightPadding\(8\), top: 8 \+ this\.insets\.top, bottom: 8 \}\)/], + ['the chat header band', conversationView, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), top: this\.topSafeInset \}\)/], + ['the composer layer', conversationView, + /\.padding\(\{ left: this\.insets\.leftPadding\(0\), right: this\.insets\.rightPadding\(0\), bottom: this\.bottomSafeInset \}\)/] + ]) { + assert.match(normalize(text), pattern, `${name} must consume the side strips through the binding`); + } + assert.match(normalize(wideConversationHost), + /\.position\(\{ x: this\.insets\.leftPadding\(12\), y: 12 \+ this\.insets\.top \}\)/, + 'the floating control must take its x offset through the binding as well'); +}); + test('every surface that owns a bottom edge reads the shared binding, not a constant', () => { for (const [name, text] of [['AppSidebar', appSidebar], ['MiniAppSurface', miniAppSurface], ['SettingsSheet', settingsSheet], ['ConversationView', conversationView], From 9a5200ebc40b4f7a9849ae3dd6cc2923bd83407a Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 22:25:53 +0800 Subject: [PATCH 06/13] feat(mobile): claim the side edges each full-bleed fill owns The four full-bleed fills already claimed SafeAreaType.CUTOUT, but their edge lists still said TOP/BOTTOM. `expandSafeArea` takes effect per edge and only where the component's own boundary meets that edge's safe-area boundary, so a fill that named the cutout type and then named only the vertical edges claimed nothing at all on the one edge a rotated cutout lands on - the window's left or right. Each fill now names exactly the edges its box owns: drawer floor, account cover scrim all four: both are the window itself; chat page's bottom fade START/END/BOTTOM, in that order, and deliberately no TOP: the layer is only as tall as the status line and the composer and sits at the bottom of the pane, so its top edge is nowhere near the window's top avoid area; home dock BOTTOM only. The dock's own fill exists in the compact composition (`wide()` is false); a side cutout only exists in a window wide enough for `wide()`, which swaps the dock for a centred, 400vp-capped transparent column inside the 40vp gutter Row. A START/END claim there would name an edge whose safe-area boundary that column does not sit on, so it could never fire. Verified on the bundled phone emulator (1256x2760 window, density 3.5). The cutout is 74px and moves as the window rotates: portrait reports left=right=0 with the cutout on top, LANDSCAPE reports left=21.14vp (74px), LANDSCAPE_INVERTED reports right=21.14vp. The drawer's content therefore starts at x=74px in landscape against the design's 70px, and the fills reach the window's edges: the leftmost columns of the landscape drawer are the drawer's own SIDEBAR_BG with no page-colour strip beside them, and the rightmost columns of the reverse rotation are the chat page's PAGE_BG. What the same evidence says about the size of the claim: this emulator does not inset the page for the cutout. The layout dump's root box is the full window, and the pre-change and post-change landscape screenshots are byte-identical, so the added edges change no pixel here and the strip is avoided by the padding the previous change added, not by the fill expanding. The claim is what keeps a device whose page *is* inset from showing the page colour beside the drawer, and it is inert rather than wrong on this one. The contract test carried the old edge lists as exact strings. It is updated, and the new assertion names each fill's edge set with its reason, so the next edit cannot quietly widen a claim to an edge the component does not own. --- .../main/ets/pages/components/AppShell.ets | 17 ++++++-- .../ets/pages/components/ConversationView.ets | 10 ++++- .../main/ets/pages/components/WelcomeHome.ets | 10 +++++ .../tools/tests/immersive-bottom.test.cjs | 39 ++++++++++++++++++- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets index e728315e2f..40e16ac111 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppShell.ets @@ -60,8 +60,15 @@ export struct AppShell { .backgroundColor(SIDEBAR_BG) // The cutout is an avoid area of its own, so the floor claims it too: a // notch the framework would otherwise leave bare has to end up under the - // same fill the rest of the window is. - .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + // same fill the rest of the window is. The floor is the window itself — + // full width and full height — so it names all four edges. + // `expandSafeArea` is per edge and takes effect only where this + // component's boundary meets that edge's safe-area boundary, so claiming + // the top strip with `TOP` says nothing about the side strip a rotated + // cutout lands on: without `START`/`END` the fill stops at the inset and + // the page colour shows beside it. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]) .hitTestBehavior(HitTestMode.None) .opacity((this.allowSidebar && this.shellState.showSidebar) ? 1 : 0) // Matched to the content card rather than to the sidebar panel: the @@ -153,7 +160,11 @@ export struct AppShell { Column().width('100%').height('100%').backgroundColor(SCRIM) // The scrim is the cover's own background over the whole shell, so a bare // notch would read as a hole in the dimming: it claims the cutout too. - .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + // Like the floor it is the whole shell, so it names the same four edges: + // a rotated cutout sits on a side edge, and the scrim has to reach it or + // the dimming has a hole in it. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]) .onClick(() => { this.shellState.showSettings = false; }) Scroll() { Column() { this.settings() } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index dec1828440..5a2a3205a5 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -232,8 +232,14 @@ export struct ConversationView { // the navigation bar this is what keeps the strip from being bare page // colour under the fade. The cutout is a separate avoid area, so the fade // claims it as well: a device that rotated its camera onto an edge the fade - // reaches would otherwise show the page colour beside the gradient. - .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.BOTTOM]) + // reaches would otherwise show the page colour beside the gradient. The + // layer is the window's full width, so a rotated cutout lands on its own + // `START`/`END` edge and has to be named here as well. `TOP` is deliberately + // absent: this layer is only as tall as the status line and the composer and + // sits at the bottom of the pane, so its top edge is nowhere near the window's + // top avoid area and naming it would claim a strip the fade never touches. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]) // Named so a layout dump can be read against the pixels: the fade's own box // is what says where the chat page's bottom edge sits and how far the // transcript can scroll, and neither is visible in the composer's bounds. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index 120e733a84..bbcef82724 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -169,6 +169,16 @@ export struct WelcomeHome { .backgroundColor(this.wide() ? TRANSPARENT : $r('app.color.welcome_dock')) // The dock's fill reaches the bottom edge of the screen. The cutout is a // separate avoid area from the system bars, so it is claimed here too. + // + // Only the bottom edge is named, and deliberately so. The cutout reaches a + // side edge only while the window is wide — a landscape phone is ~790vp and + // a landscape foldable is wider still — and at 600vp this page already + // swaps the dock for its `wide()` composition, where the dock is a centred + // column capped at 400vp with no fill of its own and the hero's own + // background carries the page. So the compact composition this fill belongs + // to is exactly the composition in which the side strips are 0, and a + // `START`/`END` claim here could never take effect: it would name edges + // whose safe-area boundary this column does not sit on. .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.BOTTOM]) .borderRadius({ topLeft: G.welcomeDockRadius, topRight: G.welcomeDockRadius }) } diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 8752d17130..05fd49cf9a 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -195,7 +195,8 @@ test('the chat page bottom layer reaches the screen edge and its composer does n // ...it may paint into the strip even where the page area still stops above // it, and it is named so a layout dump can be read against the pixels. assert.equal(call(bottom, 'expandSafeArea'), - '.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.BOTTOM])'); + '.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], ' + + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM])'); assert.equal(call(bottom, 'id'), ".id('conversation-bottom-fade')"); // The transcript borrows the layer's measured height, which is what lets the // last message scroll above the fade instead of under the composer. @@ -435,6 +436,42 @@ test('every background that owns a screen edge claims the cutout as well', () => } }); +test('each fill names the edges its own box actually touches', () => { + // Naming the cutout type is only half of the claim: `expandSafeArea` takes + // effect per edge and only where the component's own boundary meets that + // edge's safe-area boundary. A cutout in landscape sits on the window's left + // or right edge, so a fill that named only TOP/BOTTOM claimed nothing at all + // on the one edge the rotated cutout lands on, however clearly it named the + // type. Each of the four fills now names exactly the edges its box owns: + // + // drawer floor / cover scrim the window itself, all four edges; + // chat page's bottom fade the window's full width and its bottom edge, + // while its top edge sits mid-pane — naming TOP + // would claim a strip it never touches; + // home dock the bottom edge only, because the composition + // that gives the dock a fill of its own is the + // compact one, and a side cutout only exists in + // a window wide enough for `wide()`, which swaps + // that fill for a centred, capped column. + const shellBuild = appShell.slice(appShell.indexOf('build() {'), + appShell.indexOf('@Builder', appShell.indexOf('build() {'))); + for (const [name, slice, expected] of [ + ['the drawer floor', shellBuild, + '[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]'], + ['the account cover scrim', builderBody(appShell, 'CompactLoginCover'), + '[SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM, SafeAreaEdge.START, SafeAreaEdge.END]'], + ['the chat page bottom fade', builderBody(conversationView, 'BottomOverlay'), + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]'], + ['the home dock', builderBody(welcomeHome, 'Actions'), + '[SafeAreaEdge.BOTTOM]'] + ]) { + assert.equal(call(slice, 'expandSafeArea'), + `.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], ${expected})`, + `${name} must claim exactly the edges its box owns`); + } +}); + + test('every surface whose side edge is the screen edge reserves the side strips', () => { // The side strips are the camera cutout, which a rotated device moves onto the // window's left or right edge. A full-width band consumes them per side — From 69ef16618c3e113e49c7ce2af9b0b65834033980 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 22:51:04 +0800 Subject: [PATCH 07/13] feat(mobile): subscribe to the window insets once for the whole app Fourteen surfaces each opened their own window. Every `WindowInsetsBinding` ran its own `window.getLastWindow` and registered its own `avoidAreaChange` listener for one fact that belongs to one window, so the app held a subscription per mounted surface, each behind its own asynchronous window call - and the surfaces disagreed with each other for as long as those calls were in flight. `WindowSystemBarService` now owns the one window read and the one listener, shared by every binding through a reference-counted `InsetsBroadcast`: the first registration opens the shared subscription, the last release closes it, and a registration is handed the current value immediately, so a surface that mounts after the last change starts from real numbers instead of zeroes. The broadcast publishes px, the unit the window reports; each binding still converts with its own `UIContext`, because two windows on one device need not share a scale. The public shape the 14+ surfaces are written against is unchanged: `bind(uiContext, context)` and `unbind()` keep their signatures and every padding primitive stays where it was, so no component had to change. Registration is synchronous now, so a component that unmounts immediately after binding has nothing in flight to leak. The case that used to need the component's own `disposed` hand-back - a window read resolving after the last release - is handled inside the broadcast, which also hands back a read that a later open has already superseded; a generation counter is what keeps the second case from installing a listener another open still owns. The one-shot `topSystemInsetPx` read is gone, folded into the binding it was duplicating. Its only consumer was the welcome page's top strip, which read `TYPE_SYSTEM.topRect.height` once at mount: that value cannot follow a rotation, and it misses a portrait camera cutout the merged top strip reports. WelcomeHome reserves its top strip from the binding its dock already reads. Verified on the bundled phone emulator (1256x2760 window, density 3.5) against a build of the parent commit, same scenario (`immersive-bottom-drawer`), same device: portrait `uitest dumpLayout`: 220 nodes, identical apart from the status bar clock (2 nodes) - the drawer panel is [0,137]-[854,2760] in SIDEBAR_BG with its header row at x=70, the design's own 20vp, and the chat page's header band and bottom fade are unchanged; landscape (`-rotation left`, relaunched): 124 nodes, zero differences; the drawer's header row is at x=74, i.e. `max(20vp, 21.14vp)`, the side strip the device reports - the value the previous commit measured for the same scenario. The launch log states the subscription itself once per process, and rotation keeps it connected: `window insets subscription opened` is the only such line, and the values it broadcasts move with the window - `top=38.857 bottom=28 left=0 right=0` in portrait, `top=0 bottom=28 left=21.14 right=0` in landscape, and back to the portrait numbers on a live rotation, with no second subscription. The contract test asserts the count this change is about: one `on(...)` and one `off(...)` in the whole service, the first-registration/last-release reference counting, the immediate value on registration, and the hand-back of a read that resolves late or is superseded. --- .../main/ets/pages/components/WelcomeHome.ets | 23 +-- .../ets/services/WindowSystemBarService.ets | 144 +++++++++++++++--- .../tools/tests/immersive-bottom.test.cjs | 54 +++++++ 3 files changed, 183 insertions(+), 38 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index bbcef82724..a9a779e05f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -1,7 +1,7 @@ import { RemoteI18n } from '../../i18n/RemoteI18n'; import { MobileDesignTypography, MobileDesignGeometry as G } from '../../generated/MobileDesignTokens'; import { MotionPreference } from '../../services/MotionPreference'; -import { WindowInsetsBinding, WindowSystemBarService } from '../../services/WindowSystemBarService'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { WelcomeBrandFlow } from './WelcomeBrandFlow'; import { INK, PAGE_BG, WELCOME_PAGE_BG, MUTED, TRANSPARENT } from './Theme'; @@ -22,17 +22,20 @@ export struct WelcomeHome { @Local elapsed: number = 0; @Local pageHeight: number = 700; @Local pageWidth: number = 390; - @Local topInset: number = 0; // The dock paints into the strip (expandSafeArea carries its background to // the screen edge), so its buttons read the strip: the design's own clearance // where it is already taller than the indicator, the indicator's height // where it is not. + // + // The page's top strip comes from the same binding rather than a read of its + // own. It used to ask the service for the status bar height once, at mount, + // which left the header reserving the strip a landscape rotation had already + // moved the camera cutout onto, and left the value stale after the rotation. @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); private timer: number = -1; private started: number = 0; aboutToAppear(): void { - this.readTopInset(); const context = this.getUIContext().getHostContext(); if (context) { this.insets.bind(this.getUIContext(), context); @@ -44,16 +47,6 @@ export struct WelcomeHome { this.insets.unbind(); } - private readTopInset(): void { - const context = this.getUIContext().getHostContext(); - if (!context) { - return; - } - void WindowSystemBarService.topSystemInsetPx(context).then((topInsetPx: number): void => { - this.topInset = this.getUIContext().px2vp(topInsetPx); - }); - } - private stop(): void { if (this.timer >= 0) clearInterval(this.timer); this.timer = -1; } private play(): void { this.stop(); @@ -124,11 +117,11 @@ export struct WelcomeHome { .padding({ bottom: 24 }) this.Actions() }.width('100%').height(Math.max(0, - this.pageHeight - this.topInset - G.welcomeHeaderHeight)) + this.pageHeight - this.insets.top - G.welcomeHeaderHeight)) } }.width('100%') }.width('100%').height('100%').scrollBar(BarState.Off).backgroundColor(WELCOME_PAGE_BG) - .padding({ top: this.topInset }) + .padding({ top: this.insets.top }) .onSizeChange((_old: SizeOptions, size: SizeOptions) => { this.pageHeight = Number(size.height); this.pageWidth = Number(size.width); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index 06846524f9..c8332aac77 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -89,22 +89,15 @@ export class WindowSystemBarService { } } - static async topSystemInsetPx(context: Context): Promise { - try { - const appWindow = await window.getLastWindow(context); - return appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height; - } catch (_err) { - return 0; - } - } - /** * Reads the insets once and re-reads them on every avoid-area change — folding, * rotation and the soft keyboard all move the avoid areas without recreating * the page, and a stale number either clips the composer or leaves the * transcript reserving space nothing is drawn in. * - * The returned function unregisters the listener. + * The returned function unregisters the listener. This is the app's one window + * read: every surface reaches it through `WindowInsetsBinding`, which shares a + * single subscription (`InsetsBroadcast`) instead of opening one per component. */ static async observeInsets(context: Context, onChange: WindowInsetListener): Promise { try { @@ -153,6 +146,107 @@ export class WindowSystemBarService { } } +/** + * The window's one avoid-area subscription, shared by every binding. + * + * The four numbers are a fact about one window, not about one component. While + * each of the 14+ bindings opened its own `getLastWindow` and its own + * `avoidAreaChange` listener, the app held one subscription per mounted surface, + * and those surfaces disagreed with each other for as long as each one's + * asynchronous window call took to resolve: a page that mounted just after a + * rotation could lay itself out from the previous numbers while its neighbour + * already had the new ones. + * + * This is reference-counted instead. The first subscriber opens the one window + * listener, the last release closes it, and every binding in between is a + * callback on this object. A new subscriber is handed the current value as it + * registers, so a surface that mounts after the last change still starts from + * real numbers rather than from zeroes. + * + * The values it broadcasts are px, the unit the window reports. Each binding + * converts them with its own `UIContext`, because two windows on one device need + * not share a scale. + */ +class InsetsBroadcast { + private listeners: WindowInsetListener[] = []; + private current: WindowInsets = { top: 0, bottom: 0, left: 0, right: 0 }; + private subscription?: WindowInsetSubscription; + private opened: boolean = false; + /** + * Bumped by every open and every close, so a window read that resolves late + * can tell whether it still belongs to the current subscription. + */ + private generation: number = 0; + + /** + * Registers one listener and returns its release. + * + * Registration is synchronous — the shared window call happens inside `open` + * and is not awaited here — so a component that unmounts immediately after + * binding has nothing in flight to leak: it releases a listener that is + * already in the list, and the shared subscription is torn down by that same + * release if it was the last one. + */ + subscribe(context: Context, listener: WindowInsetListener): WindowInsetSubscription { + this.listeners.push(listener); + if (this.listeners.length === 1) { + this.open(context); + } + listener(this.current); + return (): void => { + this.release(listener); + }; + } + + private release(listener: WindowInsetListener): void { + const index = this.listeners.indexOf(listener); + if (index >= 0) { + this.listeners.splice(index, 1); + } + if (this.listeners.length === 0) { + this.close(); + } + } + + private open(context: Context): void { + if (this.opened) { + return; + } + this.opened = true; + this.generation += 1; + const generation = this.generation; + RemoteLogger.info('window insets subscription opened'); + void WindowSystemBarService.observeInsets(context, (insets: WindowInsets): void => { + this.current = insets; + for (const listener of this.listeners) { + listener(insets); + } + }).then((subscription: WindowInsetSubscription): void => { + // The last subscriber can be gone, or a later open can already own the + // window, before this read resolves. Either way this subscription has no + // one left to serve, so it is handed straight back instead of being kept. + if (generation !== this.generation || this.listeners.length === 0) { + subscription(); + return; + } + this.subscription = subscription; + }); + } + + private close(): void { + this.opened = false; + this.generation += 1; + if (this.subscription) { + this.subscription(); + this.subscription = undefined; + RemoteLogger.info('window insets subscription closed'); + } + } +} + +/** The process-wide instance every `WindowInsetsBinding` registers with. */ +const sharedInsetsBroadcast: InsetsBroadcast = new InsetsBroadcast(); + /** * One component's live view of the window insets, in vp. * @@ -163,9 +257,10 @@ export class WindowSystemBarService { * The sides are part of the same model: they are the camera cutout, which a * rotated device moves onto one of them, so a full-width band reserves them * before its own design spacing rather than assuming the page starts clear. - * They mount and unmount independently, so the binding owns the subscription - * rather than leaving each page to juggle one, and it holds vp because padding, - * margins and radii in this app are vp. + * They mount and unmount independently, so the binding holds a registration on + * the app's one shared subscription rather than leaving each page to juggle a + * window listener of its own, and it holds vp because padding, margins and radii + * in this app are vp. */ @ObservedV2 export class WindowInsetsBinding { @@ -178,7 +273,11 @@ export class WindowInsetsBinding { bind(uiContext: UIContext, context: Context): void { this.disposed = false; - void WindowSystemBarService.observeInsets(context, (insets: WindowInsets): void => { + // Registering is synchronous and immediately delivers the current value, so + // this surface is laid out from real numbers as soon as it mounts and is + // releasable the moment it goes. The callback still checks `disposed` + // because the broadcast can deliver a change across an unmount. + const callback: WindowInsetListener = (insets: WindowInsets): void => { if (this.disposed) { return; } @@ -187,15 +286,14 @@ export class WindowInsetsBinding { this.left = uiContext.px2vp(insets.left); this.right = uiContext.px2vp(insets.right); RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom} left=${this.left} right=${this.right}`); - }).then((subscription: WindowInsetSubscription): void => { - // The page can disappear before the window call resolves; releasing here - // keeps a listener from outliving the component that asked for it. - if (this.disposed) { - subscription(); - return; - } - this.subscription = subscription; - }); + }; + // A binding that is bound twice replaces its own registration instead of + // leaving the previous one behind on the shared subscription. + const previous = this.subscription; + this.subscription = sharedInsetsBroadcast.subscribe(context, callback); + if (previous) { + previous(); + } } unbind(): void { diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 05fd49cf9a..57a49f6276 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -70,6 +70,12 @@ function call(sourceText, name) { throw new Error(`unbalanced .${name}() call`); } +// Counts a literal, so an assertion can be about how many times a call exists +// rather than only about whether it exists at all. +function occurrences(sourceText, needle) { + return sourceText.split(needle).length - 1; +} + test('the shell runs the window full-screen for every page', () => { // Not the welcome page's private trick any more: the transcript can only reach // the bottom edge of the screen if the window stops reserving the strip above @@ -111,6 +117,54 @@ test('the window inset service is the only place the strip is measured', () => { assert.match(windowService, /appWindow\.off\('avoidAreaChange'/, 'the binding must release its listener'); }); +test('the whole app subscribes to the window once, not once per surface', () => { + // One window, one avoid-area fact. Fourteen surfaces each opening their own + // `getLastWindow` and their own listener meant fourteen subscriptions to the + // same event and a window during which two surfaces disagreed about the strip + // between them. There is one read and one listener left, and this is the + // assertion that keeps a component from quietly growing its own again: the + // per-component subscription is a regression, however convenient it looks. + assert.equal(occurrences(windowService, "appWindow.on('avoidAreaChange'"), 1, + 'the window must be subscribed to exactly once, by the shared broadcast: a second listener means a component opened its own'); + assert.equal(occurrences(windowService, "appWindow.off('avoidAreaChange'"), 1, + 'the window must be released exactly once: a second release means a component closed a listener it did not open'); + assert.equal(occurrences(windowService, 'window.getLastWindow('), 2, + 'only the full-screen setup and the shared inset read may ask for the window'); + + // Reference counted: the first registration opens the shared subscription and + // the last release closes it, so the app holds the window for exactly as long + // as one surface is actually mounted on it. + const broadcast = normalize(windowService.slice( + windowService.indexOf('class InsetsBroadcast'), + windowService.indexOf('const sharedInsetsBroadcast'))); + assert.match(broadcast, /subscribe\(context: Context, listener: WindowInsetListener\): WindowInsetSubscription/, + 'a binding must register its callback with the broadcast rather than with the window'); + assert.match(broadcast, /if \(this\.listeners\.length === 1\) \{ this\.open\(context\); \} listener\(this\.current\);/, + 'the first registration must open the shared window subscription and hand the new subscriber the current value'); + assert.match(broadcast, /if \(this\.listeners\.length === 0\) \{ this\.close\(\); \}/, + 'the last release must close the shared window subscription'); + assert.match(broadcast, /if \(generation !== this\.generation \|\| this\.listeners\.length === 0\) \{ subscription\(\); return; \}/, + 'a window read that resolves after the last release, or after a later open, must be handed straight back'); + assert.match(normalize(windowService), /this\.subscription = sharedInsetsBroadcast\.subscribe\(context, callback\);/, + 'the binding must take its registration from the shared broadcast'); + + // The binding's public shape is what the 14+ surfaces are written against, so + // it stays: bind/unbind keep their signature, and every padding primitive the + // pages consume is still here. A page that needs the top strip mounts a + // binding — the one-shot read it used to have could not follow a rotation. + assert.match(windowService, /bind\(uiContext: UIContext, context: Context\): void \{/, + 'the binding must keep the bind signature every surface already calls'); + assert.match(windowService, /unbind\(\): void \{/, 'the binding must keep its unbind'); + for (const primitive of ['leftPadding', 'rightPadding', 'sidePadding', 'bottomPadding', 'tailSpacing']) { + assert.match(windowService, new RegExp(`${primitive}\\(designSpacing: number\\): number \\{`), + `the binding must keep ${primitive} as its own primitive`); + } + assert.equal(windowService.includes('topSystemInsetPx'), false, + 'the one-shot status-bar read must stay gone: it could not follow a rotation, and the binding it was folded into can'); + assert.match(normalize(welcomeHome), /\.padding\(\{ top: this\.insets\.top \}\)/, + 'the welcome page must reserve the top strip from the shared binding'); +}); + test('the insets merge every avoid area that claims an edge, cutout included', () => { // The camera cutout is an avoid area of its own: the framework's SYSTEM area // is the status bar and the navigation bar only, so a model that read those From b90b867c5b54529db65083c6536d6b53f2b59a4e Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 22:52:17 +0800 Subject: [PATCH 08/13] test(mobile): pin the top bands the status bar content colour follows The window's status bar content colour is one value for the whole window and follows the colour mode alone (`updateSystemBars` in EntryAbility), so it is only correct while every top band the transparent status bar shows through is itself a theme-following page colour. That has been true of every page shipped so far because they all happen to paint `PAGE_BG` / `SIDEBAR_BG` / `PAGE_BG_OVERLAY` / `WELCOME_PAGE_BG` / `CARD` at their top edge - but nothing said so, and nothing would have caught the first page that does not. The comment on `updateSystemBars` now states the coupling and what a new dark-band page owes it: the band and the content colour are one decision, so the page comes back to that method - by declaring its band against the colour mode the way the others do, or by giving the app a per-page override - instead of repainting the band on its own. The contract test enumerates the bands and holds them to it: the drawer panel, the chat header band, the gallery and file preview roots, the compact and wide home roots, the three wide host panes, the connect sheet root and the welcome page. Each must paint its declared semantic page colour, and none of them may paint its top band with `MEDIA_BACKGROUND` / `MEDIA_SCRIM` (dark in both appearances) or with a hard-coded colour. The failure message names the coupling, so the next edit reads what to do rather than only that something moved. No page-level override is introduced, and none is needed yet: there is no second page with a dark top band, and the platform warns against mixing `HdsNavigation.systemBarStyle` with the window-level system bar calls this app uses. When such a page exists, the design decision belongs in `updateSystemBars` with this list as its starting point. --- .../main/ets/entryability/EntryAbility.ets | 21 +++++++ .../tools/tests/immersive-bottom.test.cjs | 61 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index a8436edc13..1ccfa79b77 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -132,6 +132,27 @@ export default class EntryAbility extends UIAbility { } } + /** + * The content colour of both system bars, for the whole window and the whole + * app. + * + * This is one value for one window, and it is derived from one thing only: the + * colour mode. There is no per-page override here, so any page whose top band + * is dark — a media viewer, an immersive title, a hero the design decides to + * paint dark — puts the light-mode ink on a dark band and the clock and the + * status icons stop being readable against it. Every top band the app ships + * today is a theme-following page colour (`PAGE_BG`, `SIDEBAR_BG`, + * `PAGE_BG_OVERLAY`, `WELCOME_PAGE_BG`, `CARD`), which is why the single value + * is correct as it stands; `immersive-bottom.test.cjs` guards that list as a + * tripwire. + * + * So: a new page that paints its own dark top band is not finished here. It has + * to come back to this method — either by declaring its band against the + * colour mode the way the others do, or by giving the app a per-page override + * (the platform's `HdsNavigation.systemBarStyle` is the page-level route, and + * the platform also warns against mixing it with the window-level calls used + * here). Either way the band and the content colour are one decision, not two. + */ private updateSystemBars(colorMode?: ConfigurationConstant.ColorMode): void { if (!this.mainWindow) { return; diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 57a49f6276..02b8d5964e 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -35,6 +35,16 @@ const welcomeHome = source('entry/src/main/ets/pages/components/WelcomeHome.ets' const remoteSurfaceHost = source('entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets'); const wideConversationHost = source('entry/src/main/ets/pages/components/WideConversationHost.ets'); +// Reads one component's build() chain, so an assertion can name the root box a +// page paints without reaching into the file's other builders. +function buildChain(sourceText) { + const start = sourceText.indexOf('build() {'); + assert.notEqual(start, -1, 'the component must keep its build()'); + const end = sourceText.indexOf('\n @Builder', start); + assert.notEqual(end, -1, 'build() must be followed by another builder'); + return sourceText.slice(start, end); +} + // Reads one @Builder out of a component, so an assertion can name the layer it // is about instead of counting matches in the whole file. function builderBody(sourceText, name) { @@ -573,3 +583,54 @@ test('every surface that owns a bottom edge reads the shared binding, not a cons assert.match(text, /this\.insets\.unbind\(\)/, `${name} must release the insets when it goes`); } }); + +test('every top band the status bar shows through is a theme-following page colour', () => { + // The window's status-bar content colour is one value for the whole window and + // follows the colour mode alone (see `updateSystemBars` in EntryAbility): the + // light ink on a light band, the dark ink on a dark one. That single value is + // correct for every page shipped today for one reason only — every top band the + // window shows through the transparent status bar is a theme-following page + // colour — and this test is what turns that from a coincidence into a contract. + // + // Each entry names the band that owns the window's top edge and the semantic + // colour it paints with. A band that becomes media chrome (`MEDIA_BACKGROUND` / + // `MEDIA_SCRIM`, dark in both appearances) or a hard-coded colour puts the + // light-mode ink on a dark band, and the clock and the status icons stop being + // readable against it. + const bands = [ + ['the drawer panel', builderBody(appSidebar, 'SidebarContent'), 'SIDEBAR_BG'], + ['the chat header band', builderBody(conversationView, 'TopOverlay'), 'PAGE_BG_OVERLAY'], + ['the gallery root', buildChain(miniAppSurface), 'PAGE_BG'], + ['the file preview root', buildChain(filePreviewSurface), 'PAGE_BG'], + ['the compact home root', builderBody(remoteSurfaceHost, 'CompactHomeContent'), 'PAGE_BG'], + ['the wide home root', builderBody(remoteSurfaceHost, 'FlowPlaceholder'), 'PAGE_BG'], + ['the wide home pane', builderBody(wideConversationHost, 'RemoteHomeContent'), 'PAGE_BG'], + ['the wide home detail', builderBody(wideConversationHost, 'RemoteHomeDetail'), 'PAGE_BG'], + ['the wide chat pane', builderBody(wideConversationHost, 'RemoteChatContent'), 'PAGE_BG'], + ['the connect sheet root', buildChain(connectView), 'CARD'], + ['the welcome page', buildChain(welcomeHome), 'WELCOME_PAGE_BG'] + ]; + const unreadable = 'the status bar icons are drawn in the colour-mode ink, so a top band that is dark in ' + + 'that same appearance makes them unreadable: handle the band and the system bar content colour together ' + + '(see `updateSystemBars` in EntryAbility) instead of repainting the band on its own'; + for (const [name, slice, token] of bands) { + assert.match(normalize(slice), new RegExp(`\\.backgroundColor\\(${token}\\)`), + `${name} must paint its top band with the theme-following ${token}`); + for (const forbidden of ['MEDIA_BACKGROUND', 'MEDIA_SCRIM']) { + assert.equal(slice.includes(forbidden), false, + `${name} must not paint its top band with ${forbidden} (dark in both appearances): ${unreadable}`); + } + assert.equal(slice.includes("backgroundColor('#"), false, + `${name} must not paint its top band with a hard-coded colour: ${unreadable}`); + } + + // The same rule for the fills that reach the screen edge rather than sitting in + // a band: the status bar strip carries whatever the page paints there, so the + // surfaces that own it are the ones that have to keep their own ink legible on + // it. Each of the three already reads a theme-following token above. + for (const [name, text] of [['AppShell', appShell], ['ConversationView', conversationView], + ['WelcomeHome', welcomeHome]]) { + assert.equal(text.includes('MEDIA_BACKGROUND'), false, + `${name} must not paint a screen-edge fill with media chrome: ${unreadable}`); + } +}); From 096a6e6a11b8f9dd05d1d38219764527d7fdde9f Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 22:53:55 +0800 Subject: [PATCH 09/13] test(mobile): pin the keyboard-avoid mode behind the bottom inset rule `insetsOf` hands the bottom strip to the keyboard while it is visible because `KeyboardAvoidMode.RESIZE` has already lifted the page above it; reserving the navigation indicator as well would count the same strip twice. The mode is set by the chat page and only while that page is mounted, so the rule depends on a page it cannot see, and nothing held that page to it: delete the call and the bottom ends up reserved by the keyboard and the navigation bar at once - the composer and everything docked below the transcript sit behind the keyboard with a bar-sized hole beneath them. ConversationView has a contract assertion for the whole pair now: it keeps the mode it found, sets RESIZE while it is mounted, and restores what it found when it goes. The failure message states the coupling, so the edit that removes the call reads why the inset rule depended on it. `TYPE_SYSTEM_GESTURE` is registered beside the merge, as the decision it is. It is the region a system return gesture can start from, and the official immersive sample reads it with the areas this app merges; this app deliberately does not, because the bottom of that region is already covered by the navigation indicator here and reserving its side part unconditionally would move every full-width band inward on a gesture-navigation device with no measured overlap to justify it. The test keeps the note and the behaviour together: reading the area is a new decision, not a drive-by. `start_window_background` was audited for the same cleanup and is not dead, so it stays. The Android Compose theme consumes it - `surfaceContainerLowest = DarkTokens.StartWindowBackground` in Theme.kt - and the token source emits one token set to every surface at once (scripts/mobile-ui-design-system.mjs), so the HarmonyOS `base`/`dark` colour elements and the generated HarmonyOS and iOS fields that appear to have no reader cannot be dropped without dropping the Android role with them, and removing only the HarmonyOS copies would desync the generated files from their source. --- .../ets/services/WindowSystemBarService.ets | 10 +++++ .../tools/tests/immersive-bottom.test.cjs | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index c8332aac77..c93f11001b 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -127,6 +127,16 @@ export class WindowSystemBarService { // declares `avoid_cutout` (see module.json5). Rotating the device moves it // from the top edge to a side one, which is why it is merged here with the // system and navigation areas instead of being left to the top strip. + // + // `TYPE_SYSTEM_GESTURE` is deliberately not read here, and that is a decision + // rather than an oversight. It is the region a system return gesture can be + // started from; the official immersive-layout sample reads it beside the + // areas above. This app registers the omission as an open follow-up instead + // of merging it now: the bottom of that region is already covered by the + // navigation indicator here, and reserving its side part unconditionally + // would move every full-width band inward on a gesture-navigation device + // with no measured overlap to justify it. Merge it in the day a swiped edge + // is observed to swallow a control. const merged = mergeAvoidEdges([ WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_SYSTEM), WindowSystemBarService.avoidEdges(appWindow, window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR), diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 02b8d5964e..4e843100cc 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -634,3 +634,44 @@ test('every top band the status bar shows through is a theme-following page colo `${name} must not paint a screen-edge fill with media chrome: ${unreadable}`); } }); + +test('the chat page owns the keyboard avoidance the bottom rule assumes', () => { + // `insetsOf` hands the bottom strip to the keyboard while it is visible, + // because `KeyboardAvoidMode.RESIZE` has already lifted the page above it: + // reserving the navigation indicator as well would count the same strip twice. + // The mode is set by the chat page itself and only while that page is mounted, + // so the rule leans on a page it cannot see. Without RESIZE the keyboard is + // still up and the bottom strip is still handed to it, so the bottom is + // reserved by the keyboard and the navigation bar both - the composer and + // everything docked below the transcript sit under the keyboard with a + // bar-sized hole beneath them. + const appear = conversationView.slice(conversationView.indexOf('aboutToAppear(): void {'), + conversationView.indexOf('aboutToDisappear(): void {')); + const disappear = conversationView.slice(conversationView.indexOf('aboutToDisappear(): void {'), + conversationView.indexOf('build() {')); + assert.match(conversationView, /private previousKeyboardAvoidMode: KeyboardAvoidMode = /, + 'the chat page must keep the mode it found, so it can put it back'); + assert.match(appear, /this\.previousKeyboardAvoidMode = this\.getUIContext\(\)\.getKeyboardAvoidMode\(\);/, + 'the chat page must read the current keyboard avoid mode before it changes it'); + assert.match(appear, /this\.getUIContext\(\)\.setKeyboardAvoidMode\(KeyboardAvoidMode\.RESIZE\);/, + 'the chat page must set RESIZE while it is mounted: the bottom inset rule in WindowSystemBarService ' + + 'assumes the keyboard has already lifted the page, so without RESIZE the bottom is reserved by the ' + + 'keyboard and the navigation bar at once'); + assert.match(disappear, + /this\.getUIContext\(\)\.setKeyboardAvoidMode\(this\.previousKeyboardAvoidMode\);/, + 'the chat page must restore the mode it found when it goes, so the rest of the shell keeps the platform default'); +}); + +test('the side gesture area is deliberately not tracked, and says so', () => { + // `TYPE_SYSTEM_GESTURE` is the region a system return gesture can start from, + // and the official immersive sample reads it beside the areas this app merges. + // The omission here is a recorded decision with a follow-up rather than an + // oversight, so the note and the behaviour are asserted together: the day one + // of them moves, the other has to move with it. + assert.match(windowService, /TYPE_SYSTEM_GESTURE/, + 'the decision not to track the gesture area must stay recorded next to the merge it belongs to'); + const insetsOf = normalize(windowService.slice(windowService.indexOf('private static insetsOf'))); + assert.equal(insetsOf.includes('TYPE_SYSTEM_GESTURE'), false, + 'reading the gesture area is a new decision: it moves where every full-width band starts, so it must not ' + + 'be merged in as a drive-by, and the note above the merge has to be rewritten with it'); +}); From 39c72727c16e8cdeb577267a850ee8319cf9ad2b Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 23:56:36 +0800 Subject: [PATCH 10/13] fix(mobile): release the inset listener when the window read fails `observeInsets` registered `avoidAreaChange` and then read the avoid areas, and the read is what throws: a window that cannot report them landed in the catch, which returned an empty release and zeroed every inset. Two things followed, and neither was visible from anywhere else. The listener stayed on the window for the life of the process - `InsetsBroadcast.close()` had nothing to call, so the app held a window listener with no surface mounted - and the shared broadcast was pinned to zeroes, so every later avoid-area change re-threw inside the system's own callback with nobody watching and the whole app laid itself out against zero insets. That is a silent, whole-app degradation, and "degrade loudly" is the rule the remote scenarios are held to. The failure path now takes back exactly the listener it put on, and only if `on` actually registered: releasing blind would detach a callback that is not ours. It logs the failure before falling back to zero insets, and the window-level failure still cannot block page rendering. --- .../ets/services/WindowSystemBarService.ets | 32 +++++++++++++++++-- .../tools/tests/immersive-bottom.test.cjs | 30 +++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index c93f11001b..da6dfbf882 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -100,19 +100,45 @@ export class WindowSystemBarService { * single subscription (`InsetsBroadcast`) instead of opening one per component. */ static async observeInsets(context: Context, onChange: WindowInsetListener): Promise { + // Swapped for the real release as soon as the window is in hand. Until then + // there is nothing on the window to take back, so the placeholder is what a + // failure before `on` registered ends up calling. + let detach: WindowInsetSubscription = (): void => {}; try { const appWindow = await window.getLastWindow(context); const read: () => void = (): void => { onChange(WindowSystemBarService.insetsOf(appWindow)); }; const onAvoidAreaChange: (data: window.AvoidAreaOptions) => void = (_data: window.AvoidAreaOptions): void => { read(); }; - const subscription: WindowInsetSubscription = (): void => { + // `listening` records whether `on` actually took. The release takes back + // exactly the listener this call put on and never one it did not: the + // failure path below runs after `on` may or may not have registered, and + // releasing blind would detach a callback that is not ours. + let listening = false; + detach = (): void => { + if (!listening) { + return; + } + listening = false; appWindow.off('avoidAreaChange', onAvoidAreaChange); }; appWindow.on('avoidAreaChange', onAvoidAreaChange); + listening = true; read(); - return subscription; - } catch (_err) { + return detach; + } catch (err) { + // A window that cannot hand over its avoid areas must not stop the page + // rendering, so this still falls back to zero insets — but it is not the + // silent no-op it used to be. The listener registered above is released + // first: leaving it on would keep the window alive past every unmount, and + // leaving the shared broadcast at zero would let every later avoid-area + // change throw inside the system's own callback with nobody watching, so + // the whole app would lay itself out against zero insets for the rest of + // the process with nothing in the log to say why. + detach(); + RemoteLogger.error( + `window insets read failed, falling back to zero insets: ${err instanceof Error ? err.message : String(err)}` + ); onChange({ top: 0, bottom: 0, left: 0, right: 0 }); return (): void => {}; } diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 4e843100cc..d335a24470 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -127,6 +127,36 @@ test('the window inset service is the only place the strip is measured', () => { assert.match(windowService, /appWindow\.off\('avoidAreaChange'/, 'the binding must release its listener'); }); +test('a window read that fails releases its listener and says so', () => { + // The failure path used to hand back an empty release and zero every inset. + // Two things followed, and neither was visible from anywhere else: the + // listener registered one line above stayed on the window for the life of the + // process — `InsetsBroadcast.close()` had nothing to call, so an app with no + // mounted surface still held a window listener — and the shared broadcast was + // pinned to zeroes, so every later avoid-area change re-threw inside the + // system's own callback with nobody watching and the whole app laid itself out + // against zero insets. That is the silent whole-app regression this test + // exists to keep out: the failure has to be released and it has to be logged. + const observe = windowService.slice( + windowService.indexOf('static async observeInsets'), + windowService.indexOf('private static avoidEdges')); + assert.notEqual(observe.indexOf('} catch'), -1, + 'observeInsets must keep a failure path: a window that cannot answer is not a reason to stop the page rendering'); + const failure = normalize(observe.slice(observe.indexOf('} catch'))); + assert.match(failure, /(?:appWindow\.off\('avoidAreaChange'|detach\(\);)/, + 'the failure path must take the listener it registered back off the window, directly or through the shared ' + + 'release: leaving it registered outlives every unmount, and zeroing the broadcast pins the whole app to zero ' + + 'insets for the rest of the process'); + assert.match(failure, /RemoteLogger\.(?:error|warn)\(/, + 'the failure path must log: silently zeroing every inset is the whole-app regression this path used to cause'); + // The release the failure path calls has to be the one that really detaches, + // and it has to be guarded by whether `on` registered at all: releasing blind + // would detach a callback that was never ours. + assert.match(normalize(windowService), + /detach = \(\): void => \{ if \(!listening\) \{ return; \} listening = false; appWindow\.off\('avoidAreaChange', onAvoidAreaChange\); \};/, + 'the shared release must be the one place the window listener is taken off, and it must not fire when nothing was registered'); +}); + test('the whole app subscribes to the window once, not once per surface', () => { // One window, one avoid-area fact. Fourteen surfaces each opening their own // `getLastWindow` and their own listener meant fourteen subscriptions to the From be5bd584fb7eedc7f615396f70851777b81d444b Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 23:56:51 +0800 Subject: [PATCH 11/13] test(mobile): count the window subscription across the tree and pin the binding lifecycle The "one window, one listener" contract was counted inside `WindowSystemBarService` alone, so a component in any other file could open `getLastWindow().then(w => w.on('avoidAreaChange', ...))` and nothing noticed - which is exactly the regression the shared broadcast exists to forbid. The count now runs over the whole `entry/src/main/ets` tree, so the assertion covers the file a component would actually be written in. `generated/` is excluded: it is emitted from the design tokens and carries no window code, so a match there would be a generator bug rather than this contract moving. Four lifecycle lines carried the whole "a surface only reads insets while it is mounted, and the app only holds the window while a surface is" rule and had no coverage at all: `unbind` marking the binding disposed, the callback's disposed guard, `close` clearing `opened`, and `bind` releasing its previous registration. Each can be deleted with every other test still green, and the `opened` one is the worst of them: with it left true the broadcast believes it is still open, so after the last surface closes and a new one opens the window listener is never rebuilt and the insets never update again. All four are now asserted by name, with the consequence each deletion has. --- .../tools/tests/immersive-bottom.test.cjs | 86 +++++++++++++++++-- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index d335a24470..138f152a05 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -86,6 +86,37 @@ function occurrences(sourceText, needle) { return sourceText.split(needle).length - 1; } +// Every `.ets` source in the entry module's `ets` tree. Some of these contracts +// are about the whole app and not about one file — "one window listener" is the +// clearest of them — and a component that breaks one lives anywhere in the tree. +// `generated/` is left out: it is emitted from the design tokens and carries no +// window code, so a match there would be a generator bug rather than this +// contract moving. +function etsSources(root) { + const files = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (entry.name === 'generated') { + continue; + } + files.push(...etsSources(path.join(root, entry.name))); + } else if (entry.name.endsWith('.ets')) { + files.push(path.join(root, entry.name)); + } + } + return files; +} + +// The whole tree as `[relativePath, text]` pairs, with `/` separators so an +// expectation reads the same on every host. +function etsTree(relativeRoot) { + const root = path.join(__dirname, '../..', relativeRoot); + return etsSources(root).map((file) => [ + path.relative(root, file).split(path.sep).join('/'), + fs.readFileSync(file, 'utf8') + ]); +} + test('the shell runs the window full-screen for every page', () => { // Not the welcome page's private trick any more: the transcript can only reach // the bottom edge of the screen if the window stops reserving the strip above @@ -164,12 +195,30 @@ test('the whole app subscribes to the window once, not once per surface', () => // between them. There is one read and one listener left, and this is the // assertion that keeps a component from quietly growing its own again: the // per-component subscription is a regression, however convenient it looks. - assert.equal(occurrences(windowService, "appWindow.on('avoidAreaChange'"), 1, - 'the window must be subscribed to exactly once, by the shared broadcast: a second listener means a component opened its own'); - assert.equal(occurrences(windowService, "appWindow.off('avoidAreaChange'"), 1, - 'the window must be released exactly once: a second release means a component closed a listener it did not open'); + // + // Counted over the whole `ets` tree, not over the service alone. Counting one + // file only proved the service did not grow a second listener; a component + // living in any other file could open one and nothing here would notice, which + // is exactly the shape this contract exists to forbid. + const tree = etsTree('entry/src/main/ets'); + const subscribingFiles = tree + .filter(([, text]) => text.includes("on('avoidAreaChange'")) + .map(([name]) => name); + assert.deepEqual(subscribingFiles, ['services/WindowSystemBarService.ets'], + 'the window must be subscribed to from the shared service and nowhere else: any other file registering ' + + '`avoidAreaChange` is a component holding its own window listener, which the app-wide release cannot reach ' + + 'and which disagrees with the shared numbers between reads'); + assert.equal(tree.reduce((count, [, text]) => count + occurrences(text, "on('avoidAreaChange'"), 0), 1, + 'the window must be subscribed to exactly once, by the shared broadcast: a second listener means a surface opened its own'); + assert.equal(tree.reduce((count, [, text]) => count + occurrences(text, "off('avoidAreaChange'"), 0), 1, + 'the window listener must be released from exactly one place, in the service that opened it: a release ' + + 'anywhere else means a component closed a listener it did not open'); + // `KeyboardReleaseService` is the tree's third window read and a deliberate + // one: it dismisses the soft keyboard, it is not an inset source, and it opens + // no avoid-area listener. The two counted here are the full-screen setup and + // the shared inset read, both in the service above. assert.equal(occurrences(windowService, 'window.getLastWindow('), 2, - 'only the full-screen setup and the shared inset read may ask for the window'); + 'only the full-screen setup and the shared inset read may ask the window service for insets'); // Reference counted: the first registration opens the shared subscription and // the last release closes it, so the app holds the window for exactly as long @@ -705,3 +754,30 @@ test('the side gesture area is deliberately not tracked, and says so', () => { 'reading the gesture area is a new decision: it moves where every full-width band starts, so it must not ' + 'be merged in as a drive-by, and the note above the merge has to be rewritten with it'); }); + +test('the binding and the broadcast keep the lifecycle branches their state depends on', () => { + // Four single lines carry the whole "a surface only reads insets while it is + // mounted, and the app only holds the window while a surface is" rule, and not + // one of them is load-bearing for any other assertion in this file. Each + // deletion breaks a different real path, so they are pinned by name. + // The broadcast is declared above the binding in the file, so each is read + // from its own declaration to the next one that follows it. + const broadcast = normalize(windowService.slice( + windowService.indexOf('class InsetsBroadcast'), + windowService.indexOf('export class WindowInsetsBinding'))); + const binding = normalize(windowService.slice(windowService.indexOf('export class WindowInsetsBinding'))); + assert.match(binding, /unbind\(\): void \{ this\.disposed = true;/, + 'unbind must mark the binding disposed: without it the shared broadcast keeps writing later changes into a ' + + 'surface that is already gone and the dead surface relayouts on every avoid-area change'); + assert.match(binding, + /const callback: WindowInsetListener = \(insets: WindowInsets\): void => \{ if \(this\.disposed\) \{ return; \}/, + 'the callback must keep its disposed guard: the broadcast delivers a change that can land across an unmount, ' + + 'and without the guard a surface that is no longer mounted is still written to'); + assert.match(binding, + /const previous = this\.subscription; this\.subscription = sharedInsetsBroadcast\.subscribe\(context, callback\); if \(previous\) \{ previous\(\); \}/, + 'bind must release its previous registration: binding twice would leave the first callback on the shared ' + + 'subscription, so the surface is updated twice and the broadcast counts a subscriber that no longer exists'); + assert.match(broadcast, /private close\(\): void \{ this\.opened = false;/, + 'close must clear opened: left true, the broadcast believes it is still open, so after the last surface ' + + 'closes and a new one opens the window listener is never rebuilt and the insets never update again'); +}); From 9d7453826b393bbce9fbd4048f6d870c5dab7e27 Mon Sep 17 00:00:00 2001 From: Tant Date: Thu, 8 Oct 2026 23:57:08 +0800 Subject: [PATCH 12/13] fix(mobile): claim the welcome dock's side edges and record where the wide detail pane's side strip lands The dock declared only `SafeAreaEdge.BOTTOM`, on the argument that its fill only exists in the compact composition and a side cutout only exists in a window wide enough for `wide()`. But `wide()` is a logical-width test and a side cutout is not: a landscape window below 600vp, a split screen, a free window and a hover posture all leave the dock sitting on the window's side edges with no side claim, which is the same page-colour sliver this branch fixed in the shell's other three full-bleed fills. The sides are claimed now; in the `wide()` composition the dock paints TRANSPARENT, so the extra claim draws nothing. The wide detail pane's bands were measured in both postures instead of assumed. With the drawer as the master pane the chat page's bands sit at the pane's left edge - x=980 on a 789vp landscape window - so the left strip only indents them: dropping the side padding moves the composer's input 73px while the band's own box does not move at all, and the band's right edge is the window's and does need it. Padding cannot be claimed per edge the way `expandSafeArea` can, and dropping both sides would put the compact header and the flush-left wide composition back under the cutout this strip exists for, so the strip stays reserved and the comments now say which edge is the window's in which parent - plus a note on the wide home header band, whose comment asserted both sides were the window's. The bands a dump has to read are named for it, the way the bottom fade already is. --- .../ets/pages/components/ConversationView.ets | 47 ++++++++++++++----- .../main/ets/pages/components/WelcomeHome.ets | 24 ++++++---- .../components/remote/RemoteSurfaceHost.ets | 22 ++++++++- .../tools/tests/immersive-bottom.test.cjs | 44 +++++++++++++---- 4 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 5a2a3205a5..08b6d7917c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -184,12 +184,26 @@ export struct ConversationView { .width('100%') // Carried by the band that owns the background, not by the wrapper: the // band is what has to reach the status bar, and its measured height is - // what the transcript borrows as its content start offset. The band is the - // window's full width too, so it reserves the side strips as well; the - // header's own gutter lives inside it and is not restated here. + // what the transcript borrows as its content start offset. Its sides are + // the window's wherever this page *is* the window — the compact layout, + // and the wide compositions that put it flush against an edge — so it + // reserves the side strips as well; the header's own gutter lives inside + // it and is not restated here. + // + // In the usual wide layout this page is the detail pane instead, and then + // only its right edge is the window's: the left one is the master pane's. + // A layout dump of that posture reads the left strip as a 21.14vp indent + // on a band that never touches that edge (see the note on `FlowPlaceholder` + // in RemoteSurfaceHost — the padding model cannot drop one side per edge, + // and dropping both would put the compact header back under the cutout). .padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), top: this.topSafeInset }) .backgroundColor(PAGE_BG_OVERLAY) .backgroundBlurStyle(BlurStyle.Thin) + // Named so a layout dump can be read against the pixels, the way the + // bottom fade is: this band's own box is what says where the page's top + // edge sits, and the side padding it carries is only visible in a dump as + // an offset between the box and its first child. + .id('conversation-top-band') this.TopEdgeFade() } .width('100%') @@ -208,10 +222,14 @@ export struct ConversationView { // The composer keeps the distance from the screen edge it had while the page // area ended above the navigation bar, and the layer's own box grows by that // strip: the gradient below is the layer's background, so it fills the strip - // and the transcript behind it reaches the bottom of the screen. The layer - // spans the window's width, so the side strips are the layer's own business - // as well: the composer's gutter is inside it, and on a rotated device the - // camera cutout would otherwise sit over the first control of the row. + // and the transcript behind it reaches the bottom of the screen. Wherever the + // layer's sides are the window's — the compact layout, and the wide + // compositions that put the page flush against an edge — they are the + // layer's own business as well: the composer's gutter is inside it, and on a + // rotated device the camera cutout would otherwise sit over the first control + // of the row. In the usual wide layout the layer is the detail pane's width + // instead, so only its right edge is the window's and the left strip is an + // indent nothing needs; see the note on `TopOverlay` above. .padding({ left: this.insets.leftPadding(0), right: this.insets.rightPadding(0), @@ -232,12 +250,15 @@ export struct ConversationView { // the navigation bar this is what keeps the strip from being bare page // colour under the fade. The cutout is a separate avoid area, so the fade // claims it as well: a device that rotated its camera onto an edge the fade - // reaches would otherwise show the page colour beside the gradient. The - // layer is the window's full width, so a rotated cutout lands on its own - // `START`/`END` edge and has to be named here as well. `TOP` is deliberately - // absent: this layer is only as tall as the status line and the composer and - // sits at the bottom of the pane, so its top edge is nowhere near the window's - // top avoid area and naming it would claim a strip the fade never touches. + // reaches would otherwise show the page colour beside the gradient. Both of + // its sides are named because `expandSafeArea` is per edge and only takes + // effect where this box's own boundary meets that edge's safe-area + // boundary: a side the compact layer owns expands, and the same claim on the + // detail pane's left side — where the master pane is in between — draws + // nothing. `TOP` is deliberately absent: this layer is only as tall as the + // status line and the composer and sits at the bottom of the pane, so its + // top edge is nowhere near the window's top avoid area and naming it would + // claim a strip the fade never touches. .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]) // Named so a layout dump can be read against the pixels: the fade's own box diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index a9a779e05f..7013483ff3 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets @@ -163,16 +163,20 @@ export struct WelcomeHome { // The dock's fill reaches the bottom edge of the screen. The cutout is a // separate avoid area from the system bars, so it is claimed here too. // - // Only the bottom edge is named, and deliberately so. The cutout reaches a - // side edge only while the window is wide — a landscape phone is ~790vp and - // a landscape foldable is wider still — and at 600vp this page already - // swaps the dock for its `wide()` composition, where the dock is a centred - // column capped at 400vp with no fill of its own and the hero's own - // background carries the page. So the compact composition this fill belongs - // to is exactly the composition in which the side strips are 0, and a - // `START`/`END` claim here could never take effect: it would name edges - // whose safe-area boundary this column does not sit on. - .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], [SafeAreaEdge.BOTTOM]) + // The sides are named as well, and the claim is only ever load-bearing in + // one combination. The dock only has a fill of its own in the compact + // composition — `wide()` swaps it for a centred column capped at 400vp + // whose background is TRANSPARENT — but `wide()` is a logical-width test + // (`pageWidth >= 600`) and a side cutout is not: a landscape window below + // 600vp still puts the camera on a side edge, and split screen, a free + // window and a hover posture all produce one. In that combination this + // column does sit on the window's left and right edges, so leaving the + // sides unnamed is the same page-colour sliver beside the fill that the + // shell's other three full-bleed backgrounds would show. Naming them in + // the wide composition costs nothing, because there the dock paints + // TRANSPARENT and an edge claim against a transparent fill draws nothing. + .expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], + [SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]) .borderRadius({ topLeft: G.welcomeDockRadius, topRight: G.welcomeDockRadius }) } @Builder diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index 897f81ced4..6ab4000728 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets @@ -298,8 +298,23 @@ export struct RemoteSurfaceHost { // page, not a sheet, so it should not grow a divider just because it is // wider. The band's box grows by the status bar strip for the same reason // the compact header's does: the wide detail pane's top edge is the - // window's top edge too, and its side edge is the window's as well, so the - // restore control keeps clear of a cutout that rotated onto that side. + // window's top edge too. + // + // The sides are not stated the same way, because the wide surface has two + // parents and only one of them is the window. Mounted by `WideRouteContent` + // the band is the whole wide page and both of its side edges are the + // window's; mounted by `WideConversationHost.RemoteHomeDetail` it is the + // detail pane, and then only its right edge is the window's — its left one + // is the master pane's, so the left strip only indents the restore control + // instead of clearing a cutout. A layout dump of the master/detail posture + // reads exactly that: the band's own box sits at the pane's left edge + // (x=980 on a 789vp landscape window) while its content starts 72px further + // in, and the same band collapsed flush against the window's left edge does + // need that strip. Padding cannot be claimed per edge the way + // `expandSafeArea` can, so the strip is reserved on both sides and the + // over-reservation is a bounded indent rather than a missing clearance; + // reverting both sides would put the compact header and the flush-left wide + // composition back under the cutout this strip exists for. .width('100%').height(76 + this.insets.top) .padding({ left: this.insets.leftPadding(16), @@ -307,6 +322,9 @@ export struct RemoteSurfaceHost { top: 14 + this.insets.top, bottom: 12 }) + // Named so a layout dump can be read against the pixels: this band is the + // one whose box-vs-content offset is the whole question above. + .id('remote-wide-home-band') this.CompactEmptyState() diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 138f152a05..202009d6e0 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -318,6 +318,24 @@ test('the module declares the cutout as an avoid area', () => { 'the module must declare avoid_cutout before its abilities'); }); +test('the bands a layout dump has to read are named', () => { + // The side strips are invisible in a screenshot. A layout dump is where they + // can be measured, and a band with side padding shows up there only as the + // offset between the band's own box and its first child — the box itself does + // not move. So the bands that carry one are named for the dump, the way the + // chat page's bottom fade already is: the chat page's top band, and the wide + // home header band whose box is the window in one parent and the wide detail + // pane in the other, which is the question its side padding turns on. + const top = builderBody(conversationView, 'TopOverlay'); + assert.equal(call(top, 'id'), ".id('conversation-top-band')", + 'the chat page top band must stay named so a dump can read its box against its first child'); + assert.match(normalize(remoteSurfaceHost), /\.id\('remote-wide-home-band'\)/, + 'the wide home header band must stay named: whether its box is the window or the detail pane is ' + + 'exactly what decides whether its side padding clears a cutout or only indents its content'); + assert.equal(call(builderBody(conversationView, 'BottomOverlay'), 'id'), ".id('conversation-bottom-fade')", + 'the chat page bottom fade must stay named'); +}); + test('the binding owns both strip numbers a surface needs', () => { // bottomPadding is for a fixed control: the design's spacing, or the strip if // the strip is larger. tailSpacing is for the end of a scrolling surface: the @@ -558,9 +576,12 @@ test('the welcome dock reads the strip through the shared binding', () => { assert.match(normalize(welcomeHome), /bottom: this\.wide\(\) \? 0 : this\.insets\.bottomPadding\(G\.welcomeDockBottom\)/, 'the dock must lift its content by the strip wherever the indicator is taller'); - assert.match(welcomeHome, - /\.expandSafeArea\(\[SafeAreaType\.SYSTEM, SafeAreaType\.CUTOUT\], \[SafeAreaEdge\.BOTTOM\]\)/, - 'the dock fill must still reach the screen edge'); + assert.equal(call(normalize(welcomeHome), 'expandSafeArea'), + '.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], ' + + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM])', + 'the dock fill must reach the screen edge on the sides as well as the bottom: `wide()` is a logical-width ' + + 'test and a side cutout is not, so a landscape window below 600vp, a split screen, a free window and a ' + + 'hover posture all leave this column sitting on the window\'s side edges with no side claim and a page-colour sliver beside its fill'); }); test('every background that owns a screen edge claims the cutout as well', () => { @@ -591,11 +612,16 @@ test('each fill names the edges its own box actually touches', () => { // chat page's bottom fade the window's full width and its bottom edge, // while its top edge sits mid-pane — naming TOP // would claim a strip it never touches; - // home dock the bottom edge only, because the composition - // that gives the dock a fill of its own is the - // compact one, and a side cutout only exists in - // a window wide enough for `wide()`, which swaps - // that fill for a centred, capped column. + // home dock the bottom edge and both sides. The sides are + // only load-bearing in one combination, but it + // is a reachable one: the dock has a fill of its + // own only in the compact composition, while a + // side cutout exists in any landscape window — + // including a sub-600vp one, a split screen, a + // free window or a hover posture, where the dock + // does sit on the window's side edges. In the + // `wide()` composition the dock paints + // TRANSPARENT, so the extra claim draws nothing. const shellBuild = appShell.slice(appShell.indexOf('build() {'), appShell.indexOf('@Builder', appShell.indexOf('build() {'))); for (const [name, slice, expected] of [ @@ -606,7 +632,7 @@ test('each fill names the edges its own box actually touches', () => { ['the chat page bottom fade', builderBody(conversationView, 'BottomOverlay'), '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]'], ['the home dock', builderBody(welcomeHome, 'Actions'), - '[SafeAreaEdge.BOTTOM]'] + '[SafeAreaEdge.START, SafeAreaEdge.END, SafeAreaEdge.BOTTOM]'] ]) { assert.equal(call(slice, 'expandSafeArea'), `.expandSafeArea([SafeAreaType.SYSTEM, SafeAreaType.CUTOUT], ${expected})`, From de32c4f70e537c7d57df89b07c734be34d8b6485 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 00:14:02 +0800 Subject: [PATCH 13/13] refactor(mobile): drop the window-service members this branch left without consumers `WindowSystemBarService.setLayoutFullScreen(context, enabled)` had zero call sites: `EntryAbility.onWindowStageCreate` calls `mainWindow.setWindowLayoutFullScreen(true)` on the window it already holds, so the service copy was never reached. `Context`/`window` stay imported for `observeInsets`; no `BusinessError` import existed here. `WindowInsetsBinding.sidePadding(designSpacing)` had zero production consumers either. A cutout lands on one side only, so `max(this.left, this.right)` on both sides would push a full-width band off-centre; the real needs are covered by the per-side `leftPadding()`/`rightPadding()` primitives, and removing it is the user's explicit decision. Its test assertion in `tools/tests/immersive-bottom.test.cjs` was removed with it, along with the entry in the paddings-primitive list and the centred-control rationale in the surrounding comment. That file also counted `window.getLastWindow(` occurrences in the service: the count drops from 2 to 1 now that the full-screen setup is gone, so the assertion and its comment were updated to keep the same contract - the shared inset read is the service's only window read. Verification: harmonyos tools tests 293 cases / 292 pass with only the pre-existing `workspace-editor.test.cjs` sidebar-hierarchy failure; `pnpm run harmony:architecture` clean; `assembleHap` reaches `PackageHap` and produces `entry-emulator-unsigned.hap` (`SignHap` still fails on the pre-existing missing signing config). --- .../ets/services/WindowSystemBarService.ets | 37 ++++--------------- .../tools/tests/immersive-bottom.test.cjs | 22 +++++------ 2 files changed, 16 insertions(+), 43 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index da6dfbf882..ab21f4d706 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -69,26 +69,14 @@ export function mergeAvoidEdges(areas: AvoidEdges[]): AvoidEdges { export type WindowInsetListener = (insets: WindowInsets) => void; export type WindowInsetSubscription = () => void; -/** Platform window adapter for surfaces that need to draw behind system bars. */ +/** + * Platform window adapter for surfaces that need to draw behind system bars. + * + * The shell's immersive layout itself is the ability's decision — `EntryAbility` + * lays the window out full-screen — and it is what makes the insets below the + * app's own to reserve rather than the framework's. + */ export class WindowSystemBarService { - /** - * Immersive layout for the whole shell, not one page: the transcript has to be - * able to reach the bottom edge of the screen, which only exists in the page - * area once the window stops reserving the navigation-bar strip. - * - * Immersive layout also moves the page's own origin to the top of the window, - * so every page that draws chrome against an edge reads the insets below - * instead of assuming the framework avoided the bars for it. - */ - static async setLayoutFullScreen(context: Context, enabled: boolean): Promise { - try { - const appWindow = await window.getLastWindow(context); - await appWindow.setWindowLayoutFullScreen(enabled); - } catch (_err) { - // Window layout is best effort and must not block page rendering. - } - } - /** * Reads the insets once and re-reads them on every avoid-area change — folding, * rotation and the soft keyboard all move the avoid areas without recreating @@ -363,17 +351,6 @@ export class WindowInsetsBinding { return Math.max(designSpacing, this.right); } - /** - * The clearance both sides need for a control that has to stay centred — a - * glyph, a pill, an icon whose own box is what the user sees. Reserving the - * wider strip on both sides is what keeps it on the window's centre line; a - * full-width band wants `leftPadding`/`rightPadding` instead, so its own - * design spacing is only ever raised on the side the device actually claims. - */ - sidePadding(designSpacing: number): number { - return Math.max(designSpacing, Math.max(this.left, this.right)); - } - /** * The tail spacer a scrolling surface appends after its last row: the strip * itself plus the design's own breathing. The viewport then runs to the diff --git a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs index 202009d6e0..c5b9dbf430 100644 --- a/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs @@ -213,12 +213,12 @@ test('the whole app subscribes to the window once, not once per surface', () => assert.equal(tree.reduce((count, [, text]) => count + occurrences(text, "off('avoidAreaChange'"), 0), 1, 'the window listener must be released from exactly one place, in the service that opened it: a release ' + 'anywhere else means a component closed a listener it did not open'); - // `KeyboardReleaseService` is the tree's third window read and a deliberate + // `KeyboardReleaseService` is the tree's other window read and a deliberate // one: it dismisses the soft keyboard, it is not an inset source, and it opens - // no avoid-area listener. The two counted here are the full-screen setup and - // the shared inset read, both in the service above. - assert.equal(occurrences(windowService, 'window.getLastWindow('), 2, - 'only the full-screen setup and the shared inset read may ask the window service for insets'); + // no avoid-area listener. The one counted here is the shared inset read above, + // which is the app's only inset source. + assert.equal(occurrences(windowService, 'window.getLastWindow('), 1, + 'only the shared inset read may ask the window service for insets'); // Reference counted: the first registration opens the shared subscription and // the last release closes it, so the app holds the window for exactly as long @@ -244,7 +244,7 @@ test('the whole app subscribes to the window once, not once per surface', () => assert.match(windowService, /bind\(uiContext: UIContext, context: Context\): void \{/, 'the binding must keep the bind signature every surface already calls'); assert.match(windowService, /unbind\(\): void \{/, 'the binding must keep its unbind'); - for (const primitive of ['leftPadding', 'rightPadding', 'sidePadding', 'bottomPadding', 'tailSpacing']) { + for (const primitive of ['leftPadding', 'rightPadding', 'bottomPadding', 'tailSpacing']) { assert.match(windowService, new RegExp(`${primitive}\\(designSpacing: number\\): number \\{`), `the binding must keep ${primitive} as its own primitive`); } @@ -286,10 +286,9 @@ test('the insets merge every avoid area that claims an edge, cutout included', ( test('the binding exposes the side strips and the paddings that consume them', () => { // Left and right are 0 on a portrait phone, so `max(design, strip)` is exactly // the design value there and the change is invisible until a device rotates - // its camera onto that side. The three primitives are there because a cutout - // only ever lands on one side: a full-width band reserves the side the device - // claims and keeps its own gutter on the other, while a control that has to - // stay centred reserves the wider of the two on both. + // its camera onto that side. The two primitives are split by side because a + // cutout only ever lands on one of them: a band reserves the side the device + // claims and keeps its own gutter on the other. for (const edge of ['top', 'bottom', 'left', 'right']) { assert.match(windowService, new RegExp(`@Trace ${edge}: number = 0;`), `the binding must publish the ${edge} strip`); @@ -302,9 +301,6 @@ test('the binding exposes the side strips and the paddings that consume them', ( assert.match(normalize(windowService), /rightPadding\(designSpacing: number\): number \{ return Math\.max\(designSpacing, this\.right\); \}/, 'a side strip must only ever raise its own side above the design spacing'); - assert.match(normalize(windowService), - /sidePadding\(designSpacing: number\): number \{ return Math\.max\(designSpacing, Math\.max\(this\.left, this\.right\)\); \}/, - 'a control that stays centred reserves the wider of the two sides'); }); test('the module declares the cutout as an avoid area', () => {