diff --git a/src/apps/mobile/harmonyos/.gitignore b/src/apps/mobile/harmonyos/.gitignore index 42c31716c8..63b8fc71c1 100644 --- a/src/apps/mobile/harmonyos/.gitignore +++ b/src/apps/mobile/harmonyos/.gitignore @@ -32,3 +32,6 @@ # Generated from shared desktop MiniApp sources by entry/hvigorfile.ts /entry/src/main/resources/rawfile/miniapps/ + +# Generated from en_US/zh_CN element string catalogs by entry/hvigorfile.ts +/entry/src/main/resources/rawfile/string.json diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 9d1ada9f07..a922e920dc 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -76,6 +76,16 @@ source scripts/ohos-env.sh "$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon ``` +For window inset subscription lifecycle and late completion regressions, run +`node --test tools/tests/window-insets-lifecycle.test.cjs tools/tests/immersive-bottom.test.cjs`. +The lifecycle tests run the production service with deferred platform window +reads; device rotation and folding still require native verification. + +For language-refresh and literal-parameter regressions, run +`node --test tools/tests/host-text-retranslator.test.cjs`. This exercises the +production retranslator and RemoteI18n with real element catalogs; platform +resource access is mocked and does not replace native resource/device checks. + For workspace/session catalog rendering, install the debug HAP and run `python3 tools/check-catalog-refresh.py --hdc "$HDC"` (add `--dark` for dark mode). Run in compact and wide postures. The isolated fixture replaces objects diff --git a/src/apps/mobile/harmonyos/build-profile.json5 b/src/apps/mobile/harmonyos/build-profile.json5 index d6fea37185..b8ab714e5f 100644 --- a/src/apps/mobile/harmonyos/build-profile.json5 +++ b/src/apps/mobile/harmonyos/build-profile.json5 @@ -4,7 +4,7 @@ { "name": "default", "signingConfig": "default", - "targetSdkVersion": "6.1.1(24)", + "targetSdkVersion": "26.0.0", "compatibleSdkVersion": "6.0.1(21)", "runtimeOS": "HarmonyOS", "buildOption": { diff --git a/src/apps/mobile/harmonyos/entry/hvigorfile.ts b/src/apps/mobile/harmonyos/entry/hvigorfile.ts index 91d8ca97dd..88474b5373 100644 --- a/src/apps/mobile/harmonyos/entry/hvigorfile.ts +++ b/src/apps/mobile/harmonyos/entry/hvigorfile.ts @@ -2,6 +2,8 @@ import { hapTasks } from '@ohos/hvigor-ohos-plugin'; // Include the current shared sources on every IDE and CLI build. require('../miniapps/generate.cjs').generate(); +// Regenerate the HostTextRetranslator i18n rawfile catalog on every build. +require('../i18n/generate.cjs').generate(); export default { system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ 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..a5e11d4441 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,11 +13,13 @@ 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' || scenarioId === 'cold-start-home' || scenarioId === 'cold-start-home-dark' || + scenarioId === 'copy-selection' || scenarioId === 'copy-selection-dark' || + scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' || scenarioId === 'interaction-mailbox' || scenarioId === 'interaction-mailbox-dark') { this.initialPage = 'pages/preview/MobileDesignGallery'; this.previewStorage = new LocalStorage(); @@ -25,9 +27,9 @@ 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' || scenarioId === 'copy-selection-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' + : 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' || scenarioId === 'copy-selection' || scenarioId === 'copy-selection-nav' || scenarioId === 'copy-selection-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT : ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET; this.context.getApplicationContext().setColorMode(colorMode); @@ -109,7 +111,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,11 +124,37 @@ 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)); } } + /** + * 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; @@ -132,16 +163,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/entry/src/main/ets/i18n/HostTextRetranslator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets index d810c635a6..16a3e4eca1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets @@ -1,4 +1,5 @@ -import { resourceManager } from '@kit.LocalizationKit'; +import { util } from '@kit.ArkTS'; +import { fillMessageTemplate } from './MessageTemplate'; interface TemplateEntry { key: string; @@ -14,65 +15,245 @@ interface StringCatalog { string: StringEntry[]; } +interface EntryLanguage { + key: string; + language: string; +} + +// Retranslates catalog-backed host text across the app catalog languages. +// +// The rawfile catalog keeps every language in one document: a trailing +// 2-3 letter lowercase dot segment (for example "common.back.zh") tags the +// entry language, and untagged entries are the generator's English block. +// +// Reverse indexes recover the catalog key; rendering always uses the requested +// language. Already localized messages therefore remain stable across refreshes. +// +// Values that several keys share in one language cannot identify their key, +// so ambiguous values are refused rather than guessed, like the legacy +// retranslate policy. export class HostTextRetranslator { - private forward: Map = new Map(); - private reverse: Map = new Map(); - private templates: TemplateEntry[] = []; + private currentLanguage: string = 'en'; + private languages: string[] = []; + private forwardByLanguage: Map> = new Map(); + private reverseByLanguage: Map> = new Map(); + private ambiguousByLanguage: Map> = new Map(); + private templatesByLanguage: Map = new Map(); constructor(locale: string) { - this.loadCatalog(locale); + this.currentLanguage = this.languageTag(locale); + this.loadCatalog(); + } + + retranslate(message: string): string { + if (message.length === 0) { + return message; + } + + // 1. Exact match per language O(1) + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const reverse = this.reverseByLanguage.get(language); + if (!reverse) { + continue; + } + const exactKey = reverse.get(message); + if (exactKey !== undefined) { + const translated = this.renderEntry(exactKey); + if (translated !== null) { + return translated; + } + } + } + + // Prefer the most specific template; an equal-specificity disagreement + // cannot identify a key safely and leaves the original message intact. + let best: string | null = null; + let bestSpecificity = -1; + let ambiguous = false; + // 2. Template match per language O(M) + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const templates = this.templatesByLanguage.get(language); + if (!templates) { + continue; + } + for (let templateIndex = 0; templateIndex < templates.length; templateIndex += 1) { + const entry = templates[templateIndex]; + const matched = this.matchTemplate(entry.template, message); + if (matched !== null) { + const translated = this.renderTemplate(entry, matched); + if (translated !== null) { + const specificity = entry.template.replace(/%[12]\$s/g, '').length; + if (specificity > bestSpecificity) { + best = translated; + bestSpecificity = specificity; + ambiguous = false; + } else if (specificity === bestSpecificity && best !== translated) { + ambiguous = true; + } + } + } + } + } + + return best !== null && !ambiguous ? best : message; } - private loadCatalog(locale: string): void { + retranslateKnown(message: string, keys: string[]): string { + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index].replace(/\./g, '_'); + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const template = this.forwardByLanguage.get(language)?.get(key); + if (template === undefined) { + continue; + } + if (template === message) { + return this.renderEntry(key) ?? message; + } + const matched = this.matchTemplate(template, message); + if (matched !== null) { + return this.renderTemplate({ key, template }, matched) ?? message; + } + } + } + return message; + } + + private loadCatalog(): void { try { const resourceManager = getContext(this).resourceManager; - const rawResource = resourceManager.getRawFileContentSync('resources/rawfile/string.json'); - let jsonString = ''; - for (let i = 0; i < rawResource.length; i++) { - jsonString += String.fromCharCode(rawResource[i]); - } + // Rawfile paths are relative to the resources/rawfile root, and the + // content arrives as UTF-8 bytes. Decode multi-byte catalog text with + // TextDecoder instead of per-byte fromCharCode, which mangles it. + const rawResource = resourceManager.getRawFileContentSync('string.json'); + const jsonString = util.TextDecoder.create('utf-8').decodeWithStream(rawResource); const catalog = JSON.parse(jsonString) as StringCatalog; const strings = catalog.string || []; - + for (let index = 0; index < strings.length; index += 1) { const entry = strings[index]; - const key = entry.name; - const value = entry.value; - this.forward.set(key, value); - this.reverse.set(value, key); - if (value.indexOf('%1$s') >= 0 || value.indexOf('%2$s') >= 0) { - this.templates.push({ key, template: value }); + const tagged = this.splitEntryLanguage(entry.name); + this.ensureLanguage(tagged.language); + const forward = this.forwardByLanguage.get(tagged.language); + const reverse = this.reverseByLanguage.get(tagged.language); + const ambiguous = this.ambiguousByLanguage.get(tagged.language); + const templates = this.templatesByLanguage.get(tagged.language); + if (!forward || !reverse || !ambiguous || !templates) { + continue; + } + forward.set(tagged.key, entry.value); + const existingKey = reverse.get(entry.value); + if (existingKey !== undefined) { + if (existingKey !== tagged.key) { + // A second key shares this value within the language, so the + // value cannot identify its key: refuse it instead of letting + // the later entry win like a plain overwrite would. + ambiguous.add(entry.value); + reverse.delete(entry.value); + } + } else if (!ambiguous.has(entry.value)) { + reverse.set(entry.value, tagged.key); + } + if (entry.value.indexOf('%1$s') >= 0 || entry.value.indexOf('%2$s') >= 0) { + templates.push({ key: tagged.key, template: entry.value }); } } + this.pruneAmbiguousTemplates(); } catch (error) { // Fallback: catalog unavailable, retranslate returns original message } } - retranslate(message: string): string { - if (message.length === 0) { - return message; + // A template is trustworthy only when its key's value is unique in every + // catalog language: one shared value in any language leaves the template + // shape unable to identify its key. Refuse such templates in every + // language, matching the legacy hasUniqueTemplate gate. + private pruneAmbiguousTemplates(): void { + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const templates = this.templatesByLanguage.get(language); + if (!templates) { + continue; + } + const kept: TemplateEntry[] = []; + for (let templateIndex = 0; templateIndex < templates.length; templateIndex += 1) { + const entry = templates[templateIndex]; + if (!this.hasAmbiguousTemplate(entry.key)) { + kept.push(entry); + } + } + this.templatesByLanguage.set(language, kept); } + } - // 1. Exact match O(1) - const exactKey = this.reverse.get(message); - if (exactKey) { - return this.forward.get(exactKey) || message; + private hasAmbiguousTemplate(key: string): boolean { + for (let languageIndex = 0; languageIndex < this.languages.length; languageIndex += 1) { + const language = this.languages[languageIndex]; + const forward = this.forwardByLanguage.get(language); + const ambiguous = this.ambiguousByLanguage.get(language); + if (!forward || !ambiguous) { + return true; + } + const value = forward.get(key); + if (value === undefined || ambiguous.has(value)) { + return true; + } } + return false; + } + + private ensureLanguage(language: string): void { + if (this.forwardByLanguage.has(language)) { + return; + } + this.forwardByLanguage.set(language, new Map()); + this.reverseByLanguage.set(language, new Map()); + this.ambiguousByLanguage.set(language, new Set()); + this.templatesByLanguage.set(language, []); + this.languages.push(language); + } - // 2. Template match O(M) - for (let index = 0; index < this.templates.length; index += 1) { - const entry = this.templates[index]; - const matched = this.matchTemplate(entry.template, message); - if (matched !== null) { - return this.fillTemplate(entry.key, matched); + private languageTag(locale: string): string { + const primary = locale.split('-')[0].toLowerCase(); + return primary.length > 0 ? primary : 'en'; + } + + private splitEntryLanguage(name: string): EntryLanguage { + const lastDot = name.lastIndexOf('.'); + if (lastDot >= 0) { + const suffix = name.slice(lastDot + 1); + if (suffix.length >= 2 && suffix.length <= 3 && suffix.toLowerCase() === suffix) { + return { key: name.slice(0, lastDot), language: suffix }; } } + // Untagged entries are the generator's English block. + return { key: name, language: 'en' }; + } - return message; + private renderEntry(baseKey: string): string | null { + const forward = this.forwardByLanguage.get(this.currentLanguage); + if (!forward) { + return null; + } + const value = forward.get(baseKey); + return value !== undefined ? value : null; + } + + private renderTemplate(entry: TemplateEntry, matched: string[]): string | null { + const forward = this.forwardByLanguage.get(this.currentLanguage); + if (!forward) { + return null; + } + const template = forward.get(entry.key); + if (template === undefined) { + return null; + } + return fillMessageTemplate(template, matched); } - private matchTemplate(template: string, message: string): string | null { + private matchTemplate(template: string, message: string): string[] | null { // Parse %1$s and %2$s placeholders const firstSlot = template.indexOf('%1$s'); if (firstSlot < 0) { @@ -90,7 +271,7 @@ export class HostTextRetranslator { return null; } const value = message.slice(prefix.length, message.length - suffix.length); - return value; + return [value]; } // Two placeholders: %1$s and %2$s @@ -106,14 +287,6 @@ export class HostTextRetranslator { } const first = body.slice(0, middleAt); const second = body.slice(middleAt + middle.length); - return `${first}${second}`; - } - - private fillTemplate(key: string, matched: string): string { - const template = this.forward.get(key) || ''; - // Replace %1$s and %2$s with the matched value - // For single placeholder, matched is the value - // For two placeholders, matched is concatenated value - return template.replace('%1$s', matched).replace('%2$s', matched); + return [first, second]; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/MessageTemplate.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/MessageTemplate.ets new file mode 100644 index 0000000000..923c4fbce7 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/MessageTemplate.ets @@ -0,0 +1,8 @@ +// Substitute only slots in the original template. Callback results are literal: +// dollar signs and placeholder-like text in user data must never be re-parsed. +export function fillMessageTemplate(template: string, values: string[]): string { + return template.replace(/%[12]\$s/g, (slot: string): string => { + const index = slot === '%1$s' ? 0 : 1; + return index < values.length ? values[index] : slot; + }); +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets index 30ab303ab9..be35260dee 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/RemoteI18n.ets @@ -1,5 +1,6 @@ import { AppLocale, APP_LOCALE_EN_US, APP_LOCALE_ZH_CN } from './AppLocale'; import { HostTextRetranslator } from './HostTextRetranslator'; +import { fillMessageTemplate } from './MessageTemplate'; import { intl } from '@kit.LocalizationKit'; @ObservedV2 @@ -45,11 +46,11 @@ export class RemoteI18n { } static f(key: string, value: string): string { - return RemoteI18n.t(key).replace('%1$s', value); + return fillMessageTemplate(RemoteI18n.t(key), [value]); } static f2(key: string, first: string, second: string): string { - return RemoteI18n.t(key).replace('%1$s', first).replace('%2$s', second); + return fillMessageTemplate(RemoteI18n.t(key), [first, second]); } static translate(language: string, key: string): string { @@ -116,23 +117,29 @@ export class RemoteI18n { } static retranslateKnown(message: string, keys: string[]): string { - for (let index = 0; index < keys.length; index += 1) { - if (RemoteI18n.textEquals(keys[index], message)) { - return RemoteI18n.t(keys[index]); - } + return RemoteI18n.hostTextRetranslator().retranslateKnown(message, keys); + } + + static retranslateWorkspaceName(name: string, path: string, workspaceId: string): string { + if (path.length > 0 || workspaceId.length > 0) { + return name; } - return RemoteI18n.retranslate(message); + return RemoteI18n.retranslateKnown(name, ['status.notConnected']); } static retranslate(message: string): string { if (message.length === 0) { return message; } + return RemoteI18n.hostTextRetranslator().retranslate(message); + } + + private static hostTextRetranslator(): HostTextRetranslator { const locale = RemoteI18n.language(); if (!RemoteI18n.retranslator || RemoteI18n.retranslatorLocale !== locale) { RemoteI18n.retranslator = new HostTextRetranslator(locale); RemoteI18n.retranslatorLocale = locale; } - return RemoteI18n.retranslator!.retranslate(message); + return RemoteI18n.retranslator!; } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets index e6a5da90b1..e073742131 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntent.ets @@ -41,6 +41,22 @@ export class PlanBuildRequest { } } +/** + * What a `CopyMessage` intent asks the clipboard owner to write. + * + * `Plain` is the default so every caller that existed before the assistant copy + * menu — the code-block copy button and the user message's "full text" copy — + * keeps its single-flavor plain write. Only `Rich` changes the payload shape. + */ +export enum MessageCopyScope { + /** Markdown source as the plain flavor plus its rendered html. */ + Rich = 'rich', + /** The Markdown source alone, one plain flavor. */ + Markdown = 'markdown', + /** A plain-text rendering of the message, one plain flavor. */ + Plain = 'plain' +} + export class ConversationIntent { readonly type: ConversationIntentType; readonly value: string; @@ -49,6 +65,7 @@ export class ConversationIntent { readonly answers?: ConversationUiQuestionAnswer; readonly filePreviewRequest?: FilePreviewRequest; readonly planBuildRequest?: PlanBuildRequest; + readonly copyScope: MessageCopyScope; mailboxAction?: InteractionMailboxAction; constructor( @@ -58,7 +75,8 @@ export class ConversationIntent { updatedInput?: Object, answers?: ConversationUiQuestionAnswer, filePreviewRequest?: FilePreviewRequest, - planBuildRequest?: PlanBuildRequest + planBuildRequest?: PlanBuildRequest, + copyScope: MessageCopyScope = MessageCopyScope.Plain ) { this.type = type; this.value = value; @@ -67,6 +85,7 @@ export class ConversationIntent { this.answers = answers; this.filePreviewRequest = filePreviewRequest; this.planBuildRequest = planBuildRequest; + this.copyScope = copyScope; } } @@ -84,6 +103,23 @@ export class ConversationIntents { return new ConversationIntent(type, value); } + /** + * The scoped copy intent. The value is already the text for that scope: the + * Markdown source for `rich`/`markdown`, the plain rendering for `plain`. + */ + static copyMessage(text: string, scope: MessageCopyScope): ConversationIntent { + return new ConversationIntent( + ConversationIntentType.CopyMessage, + text, + '', + undefined, + undefined, + undefined, + undefined, + scope + ); + } + static approveTool(toolId: string, updatedInput?: Object): ConversationIntent { return new ConversationIntent(ConversationIntentType.ApproveTool, '', toolId, updatedInput); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets index 2671a3576b..3d285970ea 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/actions/ConversationIntentDispatcher.ets @@ -1,7 +1,7 @@ import { InteractionMailboxAction } from '../../model/InteractionMailbox'; import { RemoteQuestionAnswerPayload, RemoteSession } from '../../model/RemoteModels'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; -import { ConversationIntent, ConversationIntentType } from './ConversationIntent'; +import { ConversationIntent, ConversationIntentType, MessageCopyScope } from './ConversationIntent'; import { toRemoteQuestionAnswer } from '../state/ConversationUiModels'; import { FilePreviewRequest } from '../../model/FilePreviewTarget'; @@ -18,7 +18,7 @@ export interface ConversationIntentDispatcherHooks { readonly cancel: (toolId: string) => Promise; readonly answer: (toolId: string, answers: RemoteQuestionAnswerPayload) => Promise; readonly rename: (title: string) => Promise; - readonly copy: (text: string) => Promise; + readonly copy: (text: string, scope: MessageCopyScope) => Promise; readonly retry: (text: string) => Promise; readonly selectModel: (modelId: string) => Promise; readonly pickImages: () => Promise; @@ -57,7 +57,7 @@ export class ConversationIntentDispatcher { case ConversationIntentType.AnswerQuestion: if (intent.answers) void this.hooks.answer(intent.toolId, toRemoteQuestionAnswer(intent.answers)); return; case ConversationIntentType.RenameSession: void this.hooks.rename(intent.value); return; - case ConversationIntentType.CopyMessage: void this.hooks.copy(intent.value); return; + case ConversationIntentType.CopyMessage: void this.hooks.copy(intent.value, intent.copyScope); return; case ConversationIntentType.RetryMessage: void this.hooks.retry(intent.value); return; case ConversationIntentType.SelectModel: void this.hooks.selectModel(intent.value); return; case ConversationIntentType.PickImages: void this.hooks.pickImages(); return; diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets index e02989e142..bb2215827f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/AppRootPresentation.ets @@ -98,6 +98,8 @@ export struct AppRootPresentation { SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); @Local remoteViewSettingsPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.RemoteViewSettings); + @Local fullTextPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); private verticalCreases: ConversationLayoutCrease[] = []; private horizontalCreases: ConversationLayoutHorizontalCrease[] = []; private wideQueryListener?: mediaQuery.MediaQueryListener; @@ -239,6 +241,7 @@ export struct AppRootPresentation { actions: this.actions, filePreviewLayout: this.filePreviewLayout(), sessionDetailsPlacement: this.sessionDetailsPlacement, + fullTextSheetPlacement: this.fullTextPlacement, wideMasterPaneWidth: this.wideMasterPaneWidth, wideMasterDetailGap: this.wideMasterDetailGap, wideDetailContentOffset: this.wideDetailContentOffset, @@ -280,6 +283,7 @@ export struct AppRootPresentation { // Remote both open the sidebar over the chat instead of leaving it. showBackButton: false, hoverOperateMode: this.isHoverLayout(), + fullTextSheetPlacement: this.fullTextPlacement, onRestoreSidebar: () => this.restoreWideMasterPane() }) } @@ -523,6 +527,10 @@ export struct AppRootPresentation { input, SettingsSheetKind.RemoteViewSettings ); + this.fullTextPlacement = SettingsPlacementPolicy.resolve( + input, + SettingsSheetKind.UserMessageFullText + ); } private isRtlLayout(): boolean { 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..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 @@ -58,7 +58,17 @@ 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. 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 @@ -148,7 +158,13 @@ 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. + // 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/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/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 0ccf4a68f8..9a1a3d1c81 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets @@ -1,6 +1,7 @@ import { InteractionMailboxState } from '../../model/InteractionMailbox'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { ConversationUiImage, ConversationUiMessage, ConversationUiMessageItem, ConversationUiQuestionAnswer, ConversationUiToolStatus } from '../state/ConversationUiModels'; +import { MessageCopyScope } from '../actions/ConversationIntent'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { ActivityGroupPolicy, ActivityThinkingPart } from '../policy/ActivityGroupPolicy'; import { ChatMessageStructurePolicy, StructuredRenderGroup } from '../policy/ChatMessageStructurePolicy'; @@ -9,7 +10,12 @@ import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRe import { ThinkingPresentationPolicy } from '../policy/ThinkingPresentationPolicy'; import { ACCENT, INK, LINE, STATUS_DANGER } from './Theme'; import { MessageBodyMarkdown, MessageFileCards, MessageImageGallery, MessageMarkdown } from './ChatMessageContent'; -import { ChatMessageRetryAction, ChatTypingDots, ChatUserMessageBubble } from './ChatMessageChrome'; +import { + AssistantMessageCopyAction, + ChatMessageRetryAction, + ChatTypingDots, + ChatUserMessageBubble +} from './ChatMessageChrome'; import { SubagentTaskCard } from './SubagentTaskCard'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolStatusList } from './ToolStatusList'; @@ -33,12 +39,16 @@ export struct ChatUserMessageRow { detail: '' }; @Param showRetryAction: boolean = false; + @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @Event onOpenFullText: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; build() { ChatUserMessageBubble({ item: this.item, showRetryAction: this.showRetryAction, + onCopyMessage: this.onCopyMessage, + onOpenFullText: this.onOpenFullText, onRetryMessage: this.onRetryMessage }) } @@ -72,6 +82,9 @@ export struct ChatMessageBubble { @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @Event onCopyMessageScoped: (text: string, scope: MessageCopyScope) => void = + (_text: string, _scope: MessageCopyScope) => {}; + @Event onOpenFullText: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @@ -124,14 +137,8 @@ export struct ChatMessageBubble { this.item.error || '', this.showRetryAction ? (this.item.detail || '') : '' ) - } else if (this.item.status === 'failed' && this.showRetryAction && - (this.item.detail || '').trim().length > 0) { - ChatMessageRetryAction({ - assistant: true, - retryText: this.item.detail || '', - onRetry: this.onRetryMessage - }) } + this.ActionRow() } .layoutWeight(1) .padding({ right: 2 }) @@ -142,6 +149,46 @@ export struct ChatMessageBubble { .padding({ top: 2, bottom: 2 }) } + /** + * The assistant message's bottom action row: retry and copy side by side, in + * the frozen design's order. + * + * Both occupants are optional and the row disappears when neither applies. + * Retry keeps the exact condition the previous `else if` used — including its + * exclusion by the errored branch above, which draws its own retry action — + * so moving it into this row changes no failed-state behavior. Copy is drawn + * whenever there is a body to write. + */ + @Builder + ActionRow() { + if (this.showsRetryAction() || this.copySourceText().length > 0) { + Row({ space: 10 }) { + if (this.showsRetryAction()) { + ChatMessageRetryAction({ + assistant: true, + retryText: this.item.detail || '', + onRetry: this.onRetryMessage + }) + } + if (this.copySourceText().length > 0) { + AssistantMessageCopyAction({ + source: this.copySourceText(), + onCopyScoped: this.onCopyMessageScoped + }) + } + } + .alignItems(VerticalAlign.Center) + } + } + + private showsRetryAction(): boolean { + if (this.isFailedAssistant(this.item) && (this.item.error || '').trim().length > 0) { + return false; + } + return this.item.status === 'failed' && this.showRetryAction && + (this.item.detail || '').trim().length > 0; + } + @Builder AssistantFailure(errorText: string, retryText: string) { Column({ space: 6 }) { @@ -332,7 +379,9 @@ export struct ChatMessageBubble { if (this.isUserSteeringEntry(entry)) { ChatUserMessageBubble({ item: this.steeringMessage(entry, path), - showRetryAction: false + showRetryAction: false, + onCopyMessage: this.onCopyMessage, + onOpenFullText: this.onOpenFullText }) } else if (this.isThinkingEntry(entry)) { this.Thinking( @@ -607,6 +656,18 @@ export struct ChatMessageBubble { return item.role !== 'user' && (item.status || '').toLowerCase() === 'failed'; } + /** + * The message body the copy entries act on. + * + * Deliberately the same gathering the bubble already draws from, rather than + * a second reading of `items`: a structured turn's raw Markdown lives in its + * trailing top-level text entry, and a copy path that re-derived it would + * drift from what the user is looking at. + */ + private copySourceText(): string { + return this.finalMessageText(this.item); + } + private finalMessageText(item: ConversationUiMessage): string { const structuredText = this.lastTopLevelText(item.items || []); if (structuredText.length > 0) { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets index e80d9381c9..bcb3192896 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageChrome.ets @@ -1,8 +1,32 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { MessageCopyScope } from '../actions/ConversationIntent'; +import { MarkdownParser } from '../../services/MarkdownParser'; +import { + AssistantMessageCopyMenuItem, + AssistantMessageCopyMenuPolicy +} from '../policy/AssistantMessageCopyMenuPolicy'; +import { + UserMessageMenuAction, + UserMessageMenuItem, + UserMessageMenuPolicy +} from '../policy/UserMessageMenuPolicy'; +import { UserMessageTruncationPolicy } from '../policy/UserMessageTruncationPolicy'; import { ConversationUiMessage } from '../state/ConversationUiModels'; import { MessageImageGallery } from './ChatMessageContent'; -import { ACCENT, INK, MUTED, CONTENT_ON_ACTION, STATUS_DANGER, SOFT } from './Theme'; +import { + ACCENT, + CARD, + INK, + LINE, + MUTED, + CONTENT_ON_ACTION, + SHADOW_SUBTLE, + SOFT, + STATUS_DANGER, + TRANSPARENT, + FILE_LINK +} from './Theme'; @ComponentV2 export struct ChatTypingDots { @@ -72,6 +96,8 @@ export struct ChatUserMessageBubble { detail: '' }; @Param showRetryAction: boolean = false; + @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @Event onOpenFullText: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; build() { @@ -84,7 +110,13 @@ export struct ChatUserMessageBubble { MessageImageGallery({ images: this.item.images, userStyle: true }) } if (this.visibleText().length > 0) { - Text(this.visibleText()).fontSize(MobileDesignTypography.bodyLarge.size).lineHeight(MobileDesignTypography.bodyLarge.lineHeight).fontColor(INK) + if (UserMessageTruncationPolicy.shouldTruncate(this.visibleText())) { + this.TruncatedText() + } else { + // A short message is the ordinary selectable Text: a long press + // raises the system selection menu and its handles. + this.PlainText() + } } } .padding({ @@ -108,8 +140,181 @@ export struct ChatUserMessageBubble { .padding({ top: 2, bottom: 2 }) } + @Builder + private PlainText() { + Text(this.visibleText()) + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .fontColor(INK) + .copyOption(CopyOptions.LocalDevice) + } + + /** + * The truncated bubble. The cut is made by the policy rather than by + * `maxLines` ellipsis: the full-text affordance has to sit inline after the + * ellipsis, and an ellipsized Text cannot host an appended clickable span. + * + * This Text carries no `copyOption`: the frozen design keeps selection + * handles off a bubble whose text continues past what is drawn, and answers a + * long press with the menu below instead. Handles belong to the panel. + * + * It also carries no `width`: a percentage width resolves against the row the + * bubble sits in rather than the bubble's own content box, so the Text was + * measured wider than the fill behind it. Measured on device, the bubble's + * fill ended at 1215 of a 1256-wide screen while the Text's own box ran to + * 1256, and the appended affix — the only content on the last line that + * reaches past the fill — was drawn outside the bubble and clipped there. At + * the content's own width the line breaks inside the bubble, and the + * affordance wraps onto the next line when it does not fit, which is the shape + * the frozen design specifies. + */ + @Builder + private TruncatedText() { + Text() { + Span(UserMessageTruncationPolicy.truncatedPreview(this.visibleText())) + .fontColor(INK) + Span(` ${RemoteI18n.t('chat.fullText')}`) + .fontSize(MobileDesignTypography.bodyMedium.size) + .fontColor(FILE_LINK) + .decoration({ type: TextDecorationType.Underline, color: FILE_LINK }) + .onClick(() => { + this.onOpenFullText(this.visibleText()); + }) + } + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .bindContextMenu(this.TruncatedMenu(), ResponseType.LongPress) + } + + @Builder + private TruncatedMenu() { + Menu() { + ForEach(UserMessageMenuPolicy.items(), (entry: UserMessageMenuItem) => { + MenuItem({ content: RemoteI18n.t(entry.labelKey) }) + .onClick(() => { + this.runMenuAction(entry.action); + }) + }, (entry: UserMessageMenuItem) => entry.action) + } + } + + private runMenuAction(action: UserMessageMenuAction): void { + if (action === UserMessageMenuAction.CopyFullText) { + this.onCopyMessage(this.visibleText()); + } + } + private visibleText(): string { const text = this.item.text.trim(); return text === '(空消息)' || text === '(empty message)' ? '' : text; } } + +/** + * The assistant message's copy affordance: the SOFT-weakened capsule of the + * bottom action row, beside the retry pill's ACCENT one. + * + * It opens an anchored popover rather than a bottom sheet, because the frozen + * design keeps a lightweight action list at its trigger on compact and wide + * layouts alike. The popup is bound to the control it was opened from, so it + * points at that control, dismisses on an outside tap, and carries the same + * short transition as the conversation header's menu. A row thawed from the + * reusable pool closes its menu first: the pool hands a kept `@Local` back to a + * different message. + * + * The bubble passes the message's Markdown source as the transcript holds it. + * Only the plain entry renders it down, through the parser's existing + * Markdown-to-text pass; the other two copy the source itself. + */ +@ComponentV2 +export struct AssistantMessageCopyAction { + @Param source: string = ''; + @Event onCopyScoped: (text: string, scope: MessageCopyScope) => void = + (_text: string, _scope: MessageCopyScope) => {}; + @Local showMenu: boolean = false; + + aboutToReuse(): void { + this.showMenu = false; + } + + build() { + Text(RemoteI18n.t('common.copy')) + .fontSize(MobileDesignTypography.labelSmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(INK) + .height(28) + .padding({ left: 10, right: 10 }) + .backgroundColor(SOFT) + .borderRadius(14) + .accessibilityText(RemoteI18n.t('common.copy')) + .onClick(() => { + this.showMenu = !this.showMenu; + }) + .bindPopup(this.showMenu, { + builder: () => { + this.CopyMenu(); + }, + placement: Placement.BottomLeft, + popupColor: TRANSPARENT, + enableArrow: false, + autoCancel: true, + mask: false, + targetSpace: 8, + onStateChange: (event: PopupStateChangeParam) => { + this.showMenu = event.isVisible; + } + }) + } + + /** + * The conversation popover's established surface, with the frozen copy menu + * inside it: a section title, then one 48-high row per export scope. + * + * The design's per-entry glyphs are not reproduced. The approved artboard + * carries them as HMSymbol codes with no name recorded, so no established + * asset could be reused; drawing a different one would silently replace a + * frozen element. Device verification owns that gap. + */ + @Builder + private CopyMenu() { + Column() { + Text(RemoteI18n.t('common.copy')) + .width('100%') + .height(28) + .padding({ left: 8, right: 8 }) + .fontSize(MobileDesignTypography.bodySmall.size) + .fontWeight(FontWeight.Medium) + .fontColor(MUTED) + .textAlign(TextAlign.Start) + ForEach(AssistantMessageCopyMenuPolicy.items(), (entry: AssistantMessageCopyMenuItem) => { + Text(RemoteI18n.t(entry.labelKey)) + .width('100%') + .height(48) + .padding({ left: 8, right: 8 }) + .fontSize(MobileDesignTypography.titleSmall.size) + .fontColor(INK) + .textAlign(TextAlign.Start) + .borderRadius(10) + .onClick(() => { + this.runMenuAction(entry); + }) + }, (entry: AssistantMessageCopyMenuItem) => entry.action) + } + .width(292) + .padding({ left: 12, right: 12, top: 10, bottom: 10 }) + .backgroundColor(CARD) + .border({ width: 0.5, color: LINE }) + .borderRadius(16) + .shadow({ radius: 18, color: SHADOW_SUBTLE, offsetY: 7 }) + .transition(TransitionEffect.opacity(0).animation({ duration: 150, curve: Curve.EaseOut })) + } + + private runMenuAction(entry: AssistantMessageCopyMenuItem): void { + this.showMenu = false; + this.onCopyScoped(this.payloadFor(entry.scope), entry.scope); + } + + private payloadFor(scope: MessageCopyScope): string { + return scope === MessageCopyScope.Plain ? MarkdownParser.formatInlineText(this.source) : this.source; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets index 9895965f7e..adae8d6c8f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatTimeline.ets @@ -14,6 +14,7 @@ import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; import { ChatSurface } from '../state/ChatSurface'; +import { MessageCopyScope } from '../actions/ConversationIntent'; import { CARD, INK, @@ -57,6 +58,9 @@ struct ChatAssistantTimelineRow { @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @Event onCopyMessageScoped: (text: string, scope: MessageCopyScope) => void = + (_text: string, _scope: MessageCopyScope) => {}; + @Event onOpenFullText: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @@ -98,6 +102,8 @@ struct ChatAssistantTimelineRow { onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, onCopyMessage: this.onCopyMessage, + onCopyMessageScoped: this.onCopyMessageScoped, + onOpenFullText: this.onOpenFullText, onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, onBuildPlan: this.onBuildPlan, @@ -163,6 +169,9 @@ export struct ChatTimeline { @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @Event onCopyMessageScoped: (text: string, scope: MessageCopyScope) => void = + (_text: string, _scope: MessageCopyScope) => {}; + @Event onOpenFullText: (text: string) => void = (_text: string) => {}; @Event onRetryMessage: (text: string) => void = (_text: string) => {}; @Event onOpenFilePreview: (path: string, label: string) => void = (_path: string, _label: string) => {}; @Event onBuildPlan: (path: string, name: string) => void = (_path: string, _name: string) => {}; @@ -241,6 +250,12 @@ export struct ChatTimeline { ChatUserMessageRow({ item: toConversationUiMessage(repeatItem.item.message!), showRetryAction: repeatItem.item.showRetryAction === true, + onCopyMessage: (text: string) => { + this.onCopyMessage(text); + }, + onOpenFullText: (text: string) => { + this.onOpenFullText(text); + }, onRetryMessage: (text: string) => { this.onRetryMessage(text); } @@ -272,6 +287,12 @@ export struct ChatTimeline { onCopyMessage: (text: string) => { this.onCopyMessage(text); }, + onCopyMessageScoped: (text: string, scope: MessageCopyScope) => { + this.onCopyMessageScoped(text, scope); + }, + onOpenFullText: (text: string) => { + this.onOpenFullText(text); + }, onRetryMessage: (text: string) => { this.onRetryMessage(text); }, @@ -317,6 +338,8 @@ export struct ChatTimeline { onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, onCopyMessage: this.onCopyMessage, + onCopyMessageScoped: this.onCopyMessageScoped, + onOpenFullText: this.onOpenFullText, onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, onBuildPlan: this.onBuildPlan, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets index 8d4a31a0f5..448b0a0e75 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationRouteSurface.ets @@ -4,6 +4,11 @@ import { emptyAppRootPresentationActions } from '../actions/AppRootPresentationActions'; import { AppRoute } from '../navigation/AppRouteContract'; +import { + SettingsPlacement, + SettingsPlacementPolicy, + SettingsSheetKind +} from '../policy/SettingsPlacementPolicy'; import { FilePreviewPhase, FilePreviewState } from '../state/FilePreviewState'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; @@ -30,6 +35,8 @@ export struct ConversationRouteSurface { @Param showSidebarRestoreButton: boolean = false; @Param useWidePresentation: boolean = false; @Param hoverOperateMode: boolean = false; + @Param fullTextSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); @Event onRestoreSidebar: () => void = () => {}; build() { @@ -60,6 +67,7 @@ export struct ConversationRouteSurface { composerPresentation: this.composerPresentation(), composerInputId: 'conversation-composer-input', hoverOperateMode: this.hoverOperateMode, + fullTextSheetPlacement: this.fullTextSheetPlacement, onRestoreSidebar: this.onRestoreSidebar, onIntent: (intent: ConversationIntent) => this.dispatchConversationIntent(intent) }) 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..eff105257a 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 @@ -9,9 +9,12 @@ import { RemoteLogger } from '../../services/RemoteLogger'; import { ChatComposerCapabilities } from '../state/ChatComposerCapabilities'; import { ChatSurface } from '../state/ChatSurface'; import { ConversationViewState } from '../state/ConversationViewState'; -import { ConversationIntent, ConversationIntents, ConversationIntentType } from '../actions/ConversationIntent'; +import { ConversationIntent, ConversationIntents, ConversationIntentType, MessageCopyScope } from '../actions/ConversationIntent'; +import { SettingsPlacement } from '../policy/SettingsPlacementPolicy'; +import { AdaptiveSheetOptions } from './AdaptiveSheetOptions'; import { ChatTimeline } from './ChatTimeline'; import { ConversationLoadingState } from './ConversationLoadingState'; +import { UserMessageFullTextSheet } from './UserMessageFullTextSheet'; import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; import { ConversationViewContract, @@ -48,6 +51,17 @@ export struct ConversationView { @Event onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; @Event onRestoreSidebar: () => void = () => {}; @Local showHeaderActions: boolean = false; + /** + * The user message whose full text is open, as a panel over the conversation. + * + * One panel at a time is the whole reason this is state of the conversation + * rather than of the row that was long-pressed: the rows are virtualized and + * reused, and a sheet bound per row would fight the pool instead of the + * message it belongs to. It is view state, so it never becomes a projected + * field or an intent: nothing outside this pane reads it. + */ + @Local showFullText: boolean = false; + @Local fullTextMessage: string = ''; /** * How much of the pane the two floating layers cover, measured rather than * assumed: the composer grows with multiline text and attachments, and the @@ -103,6 +117,17 @@ export struct ConversationView { .width('100%') .height('100%') .backgroundColor(PAGE_BG) + .bindSheet($$this.showFullText, this.FullTextSheet(), this.fullTextSheetOptions()) + } + + @Builder + private FullTextSheet() { + UserMessageFullTextSheet({ + text: this.fullTextMessage, + onClose: () => { + this.showFullText = false; + } + }) } /** @@ -184,10 +209,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. - .padding({ top: this.topSafeInset }) + // 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%') @@ -206,8 +247,19 @@ 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. 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), + 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 +273,19 @@ 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. 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 // 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. @@ -377,6 +440,12 @@ export struct ConversationView { onCopyMessage: (text: string) => { this.onCopyMessage(text); }, + onCopyMessageScoped: (text: string, scope: MessageCopyScope) => { + this.onCopyMessageScoped(text, scope); + }, + onOpenFullText: (text: string) => { + this.onOpenFullText(text); + }, onRetryMessage: (text: string) => { this.onRetryMessage(text); }, @@ -618,6 +687,7 @@ export struct ConversationView { private get composerPresentation(): ComposerPresentation { return this.options.composerPresentation; } private get composerInputId(): string { return this.options.composerInputId; } private get hoverOperateMode(): boolean { return this.options.hoverOperateMode; } + private get fullTextSheetPlacement(): SettingsPlacement { return this.options.fullTextSheetPlacement; } private onOpenSidebar(): void { this.dispatchSimple(ConversationIntentType.OpenSidebar); } private onBack(): void { this.dispatchSimple(ConversationIntentType.Back); } private onTogglePinSession(): void { this.dispatchSimple(ConversationIntentType.TogglePin); } @@ -642,7 +712,23 @@ export struct ConversationView { this.dispatchValue(ConversationIntentType.RenameSession, title); } private onCopyMessage(text: string): void { this.dispatchValue(ConversationIntentType.CopyMessage, text); } + private onCopyMessageScoped(text: string, scope: MessageCopyScope): void { + this.onIntent(ConversationIntents.copyMessage(text, scope)); + } private onRetryMessage(text: string): void { this.dispatchValue(ConversationIntentType.RetryMessage, text); } + private onOpenFullText(text: string): void { + if (text.length === 0) { + return; + } + this.fullTextMessage = text; + this.showFullText = true; + } + private fullTextSheetOptions(): SheetOptions { + // The same adaptive contract as the other overlay sheets: the content draws + // the surface, the sheet supplies transparency, the scrim, and a drag bar + // wherever the placement resolves to a bottom sheet. + return AdaptiveSheetOptions.fromPlacement(this.fullTextSheetPlacement, true); + } private onOpenFilePreview(path: string, label: string): void { this.onIntent(ConversationIntents.openFilePreview(path, label)); } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets index c91522fb90..904a3c74df 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewContract.ets @@ -1,5 +1,10 @@ import { ChatTimelineItem } from '../../model/ChatTimelineModels'; import { ConversationMessageRenderPolicy } from '../policy/ConversationMessageRenderPolicy'; +import { + SettingsPlacement, + SettingsPlacementPolicy, + SettingsSheetKind +} from '../policy/SettingsPlacementPolicy'; import { ChatSurface } from '../state/ChatSurface'; import { toConversationUiMessage } from '../state/ConversationUiModels'; import { ComposerPresentation } from './ComposerBar'; @@ -14,6 +19,14 @@ export class ConversationViewOptions { composerPresentation: ComposerPresentation = ComposerPresentation.Compact; composerInputId: string = 'conversation-composer-input'; hoverOperateMode: boolean = false; + /** + * Placement for the full-text panel. The page owns the geometry it comes + * from, so the conversation consumes it instead of re-deriving sheets from a + * width of its own — which is what keeps the panel inside the operate half + * of a half-folded screen and inside a foldable's trailing leaf. + */ + fullTextSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); } export class ConversationViewContract { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets index a4d46a0665..8094df5bc4 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationViewHost.ets @@ -3,6 +3,11 @@ import { ConversationView } from './ConversationView'; import { ConversationIntent } from '../actions/ConversationIntent'; import { ComposerPresentation } from './ComposerBar'; import { ConversationViewOptions } from './ConversationViewContract'; +import { + SettingsPlacement, + SettingsPlacementPolicy, + SettingsSheetKind +} from '../policy/SettingsPlacementPolicy'; @ComponentV2 export struct ConversationViewHost { @@ -15,6 +20,8 @@ export struct ConversationViewHost { @Param composerPresentation: ComposerPresentation = ComposerPresentation.Compact; @Param composerInputId: string = 'conversation-composer-input'; @Param hoverOperateMode: boolean = false; + @Param fullTextSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); @Param onRestoreSidebar: () => void = () => {}; @Param onIntent: (intent: ConversationIntent) => void = (_intent: ConversationIntent) => {}; @@ -37,6 +44,7 @@ export struct ConversationViewHost { options.composerPresentation = this.composerPresentation; options.composerInputId = this.composerInputId; options.hoverOperateMode = this.hoverOperateMode; + options.fullTextSheetPlacement = this.fullTextSheetPlacement; return options; } 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/MarkdownContent.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets index c2b1e05a48..35ee702020 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MarkdownContent.ets @@ -1,5 +1,10 @@ +// `SelectionContainerAttribute` is not decoration: the ArkUI compiler resolves +// the attribute type of a component that lives in an api/ declaration through +// this file's imports, so dropping it fails CompileArkTS. +import { SelectionContainer, SelectionContainerAttribute, SelectionContainerTextJoinStyle } from '@kit.ArkUI'; import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { DeviceApiLevelService } from '../../services/DeviceApiLevelService'; import { MarkdownParseCache, ParsedMarkdownBlock, @@ -8,11 +13,18 @@ import { ParsedMarkdownTableCell, ParsedMarkdownTableRow } from '../../services/MarkdownParser'; +import { SelectionCapabilityPolicy } from '../policy/SelectionCapabilityPolicy'; import { CARD, FILE_LINK, INK, LINE, MUTED, SOFT } from './Theme'; @ComponentV2 export struct MarkdownContent { private readonly parseCache: MarkdownParseCache = new MarkdownParseCache(); + /** + * Whether this device can select across blocks. Resolved once: a running + * process cannot change its API level. + */ + private readonly crossBlockSelection: boolean = + SelectionCapabilityPolicy.supportsCrossBlockSelection(DeviceApiLevelService.sdkApiVersion()); @Param text: string = ''; // Explicit primitive invalidation for retained Repeat rows. The text remains // the source of truth; this revision only ensures parameter propagation on @@ -22,6 +34,40 @@ export struct MarkdownContent { @Event onOpenLink: (reference: string, label: string) => void = (_reference: string, _label: string) => {}; build() { + if (this.crossBlockSelection) { + // API 26 only: the container is what lets one long press span several + // `Text` children. Its `copyOption` and join style govern the system + // selection menu, which copies the selected range with a line break + // between the blocks it covers. The children keep their own `copyOption` + // as well: that is the documented shape, and it is also what keeps each + // block copyable on its own. + // + // Colors are deliberately left to the platform. The app has no caret or + // selection-highlight token, and native color resources are generated from + // the shared mobile token contract, so inventing one here would rewrite + // the Android, iOS and design-preview token outputs. The platform paints + // its own interactive-selection state, which is what the approved UI draft + // specifies for the missing tokens; the handles take the app's information + // accent so selection reads as this app's. + SelectionContainer() { + this.MarkdownBlockColumn() + } + .width('100%') + .copyOption(CopyOptions.LocalDevice) + .textJoinStyle(SelectionContainerTextJoinStyle.NEWLINE) + .caretColor(FILE_LINK) + } else { + this.MarkdownBlockColumn() + } + } + + /** + * The rendered answer. Both selection paths draw this exact subtree; only the + * wrapper differs, so the fallback below API 26 cannot drift from the API 26 + * presentation. + */ + @Builder + MarkdownBlockColumn() { Column({ space: 5 }) { ForEach(this.parseCache.blocksFor(this.text), (block: ParsedMarkdownBlock) => { this.MarkdownBlockView(block) @@ -79,6 +125,10 @@ export struct MarkdownContent { .lineHeight(MobileDesignTypography.bodySmall.lineHeight) .fontColor(MUTED) .fontFamily('monospace') + // `copyOption` is what carries selection; without it `textSelectable` + // alone is inert. Kept alongside it because it states the intent that this + // surface is read-only: selectable, never focusable. + .copyOption(CopyOptions.LocalDevice) .textSelectable(TextSelectableMode.SELECTABLE_UNFOCUSABLE) } @@ -93,6 +143,10 @@ export struct MarkdownContent { .lineHeight(lineHeight) .fontColor(color) .width('100%') + // Paragraphs, headings, quotes and list items all render through this one + // Text, so its `copyOption` is the fallback path for every prose block: one + // block per long press on a device without `SelectionContainer`. + .copyOption(CopyOptions.LocalDevice) } @Builder @@ -235,6 +289,10 @@ export struct MarkdownContent { .fontWeight(header ? FontWeight.Medium : FontWeight.Regular) .fontColor(INK) .textAlign(this.tableTextAlign(cell.alignment)) + // A cell is its own Text because the table lays cells out in Rows, so it + // needs its own copyOption to be selectable inside the container and on + // the fallback path alike. + .copyOption(CopyOptions.LocalDevice) } .layoutWeight(1) .constraintSize({ minWidth: 132, minHeight: 42 }) @@ -249,10 +307,12 @@ export struct MarkdownContent { Text(language.length > 0 ? language : 'code') .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) + .textSelectable(TextSelectableMode.UNSELECTABLE) Blank() Text(RemoteI18n.t('common.copy')) .fontSize(MobileDesignTypography.labelSmall.size) .fontColor(MUTED) + .textSelectable(TextSelectableMode.UNSELECTABLE) .onClick(() => { this.onCopyText(body); }) @@ -265,6 +325,14 @@ export struct MarkdownContent { .fontColor(INK) .fontFamily('monospace') .constraintSize({ minWidth: '100%' }) + // The code body scrolls horizontally inside the block, so it carries + // its own copyOption rather than relying on the container to reach it. + // The language label and the copy action above stay outside the + // selection: measured on device, a container selection that spans the + // block copied those two labels as well, so the block's chrome reached + // the clipboard as text. `UNSELECTABLE` is what keeps a label out of a + // selection the container drives; the copy action's click still fires. + .copyOption(CopyOptions.LocalDevice) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) 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/UserMessageFullTextSheet.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/UserMessageFullTextSheet.ets new file mode 100644 index 0000000000..40da3e6ff7 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/UserMessageFullTextSheet.ets @@ -0,0 +1,69 @@ +import { MobileDesignTypography } from '../../generated/MobileDesignTokens'; +import { RemoteI18n } from '../../i18n/RemoteI18n'; +import { WindowInsetsBinding } from '../../services/WindowSystemBarService'; +import { SheetCloseHeader } from './SheetCloseHeader'; +import { SHEET_HORIZONTAL_PADDING, SHEET_TOP_RADIUS } from './SheetLayout'; +import { INK, PAGE_BG } from './Theme'; + +/** + * One user message in full, opened from the truncated bubble's inline "full + * text" affordance. + * + * The body is a plain selectable Text on purpose: this is the surface where a + * long message is read and selected with the system selection menu and its + * handles, so it carries `CopyOptions.LocalDevice` while the truncated bubble + * it was opened from deliberately does not. Nothing else lives here — the + * sheet's placement, scrim and drag bar belong to the adaptive sheet contract + * the conversation binds it with. + */ +@ComponentV2 +export struct UserMessageFullTextSheet { + @Param text: string = ''; + @Event onClose: () => void = () => {}; + // The sheet is a bottom sheet under the immersive shell, so it reads the + // navigation-bar strip itself and ends in a tail that rests the text above it. + @Local insets: WindowInsetsBinding = new WindowInsetsBinding(); + + aboutToAppear(): void { + const context = this.getUIContext().getHostContext(); + if (context) { + this.insets.bind(this.getUIContext(), context); + } + } + + aboutToDisappear(): void { + this.insets.unbind(); + } + + build() { + Column() { + SheetCloseHeader({ + title: RemoteI18n.t('chat.fullText'), + showDivider: true, + onClose: this.onClose + }) + Scroll() { + Text(this.text) + .width('100%') + .fontSize(MobileDesignTypography.bodyLarge.size) + .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) + .fontColor(INK) + .copyOption(CopyOptions.LocalDevice) + .padding({ + left: SHEET_HORIZONTAL_PADDING, + right: SHEET_HORIZONTAL_PADDING, + top: 18, + bottom: this.insets.tailSpacing(24) + }) + } + .width('100%') + .layoutWeight(1) + .align(Alignment.TopStart) + .scrollBar(BarState.Off) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .borderRadius({ topLeft: SHEET_TOP_RADIUS, topRight: SHEET_TOP_RADIUS }) + } +} 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..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 @@ -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); @@ -167,7 +160,23 @@ 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. + // + // 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/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index e334010441..43f5168eab 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 @@ -50,6 +50,8 @@ export struct WideConversationHost { @Param filePreviewLayout: FilePreviewLayout = new FilePreviewLayout(FilePreviewPlacement.Hidden); @Param sessionDetailsPlacement: SettingsPlacement = SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails); + @Param fullTextSheetPlacement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); @Param wideMasterPaneWidth: number = 0; @Param wideMasterDetailGap: number = 0; @Param wideDetailContentOffset: number = 0; @@ -136,9 +138,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) } } @@ -362,6 +366,7 @@ export struct WideConversationHost { this.filePreviewLayout ), useWidePresentation: true, + fullTextSheetPlacement: this.fullTextSheetPlacement, onRestoreSidebar: this.onRestoreMasterPane }) } 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..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 @@ -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 { @@ -293,8 +299,32 @@ export struct RemoteSurfaceHost { // 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. + // + // 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: 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 + }) + // 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/entry/src/main/ets/pages/policy/AssistantMessageCopyMenuPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AssistantMessageCopyMenuPolicy.ets new file mode 100644 index 0000000000..43aeec8247 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AssistantMessageCopyMenuPolicy.ets @@ -0,0 +1,60 @@ +import { MessageCopyScope } from '../actions/ConversationIntent'; + +/** + * What one assistant copy entry writes. + * + * Separate from the clipboard scope because the action names the entry and the + * scope names the payload the clipboard owner receives: today the three entries + * map one to one, and this list is the only place that pairs them. + */ +export enum AssistantMessageCopyAction { + RichText = 'copy_rich_text', + Markdown = 'copy_markdown', + PlainText = 'copy_plain_text' +} + +export class AssistantMessageCopyMenuItem { + readonly action: AssistantMessageCopyAction; + /** The clipboard scope this entry copies with. */ + readonly scope: MessageCopyScope; + /** Dotted i18n key, resolved by the action row at render time. */ + readonly labelKey: string; + + constructor(action: AssistantMessageCopyAction, scope: MessageCopyScope, labelKey: string) { + this.action = action; + this.scope = scope; + this.labelKey = labelKey; + } +} + +/** + * The lightweight menu an assistant message's copy affordance opens. + * + * Items are resolved as data rather than drawn inline, so the menu's contents + * stay assertable without a device and a further export scope can arrive as one + * more entry. Every entry copies the same message body through a different + * clipboard scope: the row that renders it derives the plain rendering for + * `Plain` and passes the Markdown source unchanged for the other two. Labels + * stay as keys so this policy owns no copy and no locale. + */ +export class AssistantMessageCopyMenuPolicy { + static items(): AssistantMessageCopyMenuItem[] { + return [ + new AssistantMessageCopyMenuItem( + AssistantMessageCopyAction.RichText, + MessageCopyScope.Rich, + 'chat.copyRich' + ), + new AssistantMessageCopyMenuItem( + AssistantMessageCopyAction.Markdown, + MessageCopyScope.Markdown, + 'chat.copyMarkdown' + ), + new AssistantMessageCopyMenuItem( + AssistantMessageCopyAction.PlainText, + MessageCopyScope.Plain, + 'chat.copyPlain' + ) + ]; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SelectionCapabilityPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SelectionCapabilityPolicy.ets new file mode 100644 index 0000000000..738183ebb2 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SelectionCapabilityPolicy.ets @@ -0,0 +1,28 @@ +/** + * Which selection surface a device can offer over rendered markdown. + * + * `SelectionContainer` (API 26) is the only way to make a column of sibling + * `Text` components one continuous selection, so a handle can be dragged from a + * paragraph into the list item or code block underneath it. Below 26 the same + * subtree stays selectable one `Text` at a time through each child's own + * `copyOption`. + * + * The gate is given the device's reported API level instead of reading it, so + * the boundary stays host-testable and the platform read stays behind a service + * (see the mobile architecture rules for components). + */ +export class SelectionCapabilityPolicy { + /** First API level that ships `SelectionContainer`. */ + static readonly CROSS_BLOCK_SELECTION_API_LEVEL: number = 26; + + static supportsCrossBlockSelection(sdkApiVersion: number): boolean { + // Anything that is not a finite number is not evidence of API 26. A device + // whose level cannot be measured keeps the fallback path, which still copies + // the block the user pressed on, instead of risking an API-26 call that the + // platform would reject. + if (!Number.isFinite(sdkApiVersion)) { + return false; + } + return sdkApiVersion >= SelectionCapabilityPolicy.CROSS_BLOCK_SELECTION_API_LEVEL; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets index 7c53ff6dc9..fc4efb4035 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SettingsPlacementPolicy.ets @@ -16,7 +16,13 @@ export enum SettingsSheetKind { Settings = 'settings', Connect = 'connect', SessionDetails = 'session_details', - RemoteViewSettings = 'remote_view_settings' + RemoteViewSettings = 'remote_view_settings', + /** + * The conversation's full-text panel. It is content rather than settings, but + * it is placed by the same contract: an overlay sheet that must land inside a + * foldable's leaf, and inside the operate half of a half-folded screen. + */ + UserMessageFullText = 'user_message_full_text' } export class SettingsPlacement { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageMenuPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageMenuPolicy.ets new file mode 100644 index 0000000000..dadb6c0f1e --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageMenuPolicy.ets @@ -0,0 +1,32 @@ +/** + * The lightweight menu a truncated user bubble opens on a long press. + * + * Items are resolved as data rather than drawn inline: the menu is the entry + * point session rollback will be added to, and a list the bubble iterates is + * what lets that arrive as one more entry plus a capability check, without + * touching the bubble's layout. Labels stay as keys so this policy owns no copy + * and no locale. + */ +export enum UserMessageMenuAction { + /** Copies the whole message, not the truncated preview. */ + CopyFullText = 'copy_full_text' +} + +export class UserMessageMenuItem { + readonly action: UserMessageMenuAction; + /** Dotted i18n key, resolved by the bubble at render time. */ + readonly labelKey: string; + + constructor(action: UserMessageMenuAction, labelKey: string) { + this.action = action; + this.labelKey = labelKey; + } +} + +export class UserMessageMenuPolicy { + static items(): UserMessageMenuItem[] { + return [ + new UserMessageMenuItem(UserMessageMenuAction.CopyFullText, 'chat.copyFullText') + ]; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageTruncationPolicy.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageTruncationPolicy.ets new file mode 100644 index 0000000000..6f6f0c8c5c --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageTruncationPolicy.ets @@ -0,0 +1,101 @@ +/** + * Long user messages are read as a truncated preview with an inline affordance + * that opens the whole message. + * + * The rule is frozen at eight lines or 360 characters, whichever comes first, + * with an ellipsis the bubble appends after the cut. The policy is deliberately + * width-agnostic: the bubble's own width changes with the posture and with the + * fold, and measuring the message by its content — the way the composer and the + * markdown parser already measure text — keeps one rule for compact and wide + * instead of a line count that silently moves with the window. + * + * A message is never shown as truncated when the cut would hide nothing but + * trailing whitespace, so the preview and the ellipsis agree with + * `shouldTruncate` by construction. + */ +export class UserMessageTruncationPolicy { + /** Frozen visual threshold: the preview keeps at most this many lines. */ + static readonly MAX_PREVIEW_LINES: number = 8; + /** Frozen visual threshold: the preview keeps at most this many characters. */ + static readonly MAX_PREVIEW_CHARS: number = 360; + /** Appended after the cut, before the inline full-text affordance. */ + static readonly ELLIPSIS: string = '...'; + + /** + * One line-ending convention for counting, the same one `MarkdownParser` + * applies to message text: CRLF collapses to LF so a Windows-authored + * message does not spend two characters per line break. + */ + static normalize(text: string): string { + return text.replace(/\r\n/g, '\n'); + } + + static shouldTruncate(text: string): boolean { + return UserMessageTruncationPolicy.cutIndex( + UserMessageTruncationPolicy.normalize(text) + ) >= 0; + } + + /** + * The text the truncated bubble renders, ending in the ellipsis. A message + * that is not truncated is returned normalized and otherwise untouched, so a + * caller can render this unconditionally. + * + * Trailing whitespace is dropped before the ellipsis: a character cut can + * land just past a line break, and an ellipsis that starts a line of its own + * reads as a separate paragraph rather than as the end of the sentence. + */ + static truncatedPreview(text: string): string { + const normalized = UserMessageTruncationPolicy.normalize(text); + const cut = UserMessageTruncationPolicy.cutIndex(normalized); + if (cut < 0) { + return normalized; + } + const body = normalized.substring(0, cut).replace(/[ \t\n]+$/, ''); + return body + UserMessageTruncationPolicy.ELLIPSIS; + } + + /** + * Where the preview stops, or -1 when the whole message is shown. Line and + * character limits compete: whichever is reached first wins, so a long + * one-liner and a tall short-line message both stop at a readable amount. + */ + private static cutIndex(normalized: string): number { + const cut = UserMessageTruncationPolicy.limitIndex(normalized); + if (cut < 0 || normalized.substring(cut).trim().length === 0) { + return -1; + } + return cut; + } + + private static limitIndex(normalized: string): number { + const lineCut = UserMessageTruncationPolicy.lineCutIndex(normalized); + if (normalized.length <= UserMessageTruncationPolicy.MAX_PREVIEW_CHARS) { + return lineCut; + } + const charCut = UserMessageTruncationPolicy.MAX_PREVIEW_CHARS; + if (lineCut < 0) { + return charCut; + } + return Math.min(lineCut, charCut); + } + + /** + * The index of the line break that starts the ninth line, which is the cut + * that leaves eight whole lines in the preview. A message of eight lines or + * fewer has no such break and is not truncated for being tall. + */ + private static lineCutIndex(normalized: string): number { + let lineBreaks: number = 0; + for (let index: number = 0; index < normalized.length; index += 1) { + if (normalized.charAt(index) !== '\n') { + continue; + } + lineBreaks += 1; + if (lineBreaks === UserMessageTruncationPolicy.MAX_PREVIEW_LINES) { + return index; + } + } + return -1; + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CopySelectionPreview.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CopySelectionPreview.ets new file mode 100644 index 0000000000..079d3e684f --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CopySelectionPreview.ets @@ -0,0 +1,405 @@ +import { BusinessError, pasteboard } from '@kit.BasicServicesKit'; +import { ObservableChatTimelineItem } from '../../model/ChatTimelineModels'; +import { ChatMessage } from '../../model/RemoteModels'; +import { ClipboardService } from '../../services/ClipboardService'; +import { DeviceApiLevelService } from '../../services/DeviceApiLevelService'; +import { MarkdownHtmlSerializer } from '../../services/MarkdownHtmlSerializer'; +import { ConversationIntent, ConversationIntentType, MessageCopyScope } from '../actions/ConversationIntent'; +import { ComposerPresentation } from '../components/ComposerBar'; +import { ConversationView } from '../components/ConversationView'; +import { ConversationViewOptions } from '../components/ConversationViewContract'; +import { INK, LINE, MUTED, PAGE_BG, SOFT } from '../components/Theme'; +import { WideLayoutGeometry } from '../layout/WideLayoutGeometry'; +import { AdaptiveLayoutInput } from '../policy/AdaptiveLayoutInput'; +import { ConversationLayoutPolicy } from '../policy/ConversationLayoutPolicy'; +import { SelectionCapabilityPolicy } from '../policy/SelectionCapabilityPolicy'; +import { + SettingsPlacement, + SettingsPlacementPolicy, + SettingsSheetKind +} from '../policy/SettingsPlacementPolicy'; +import { ChatSurface } from '../state/ChatSurface'; +import { REMOTE_CHAT_COMPOSER_CAPABILITIES } from '../state/ChatComposerCapabilities'; +import { ConversationViewState } from '../state/ConversationViewState'; + +/** + * A message short enough to stay on the ordinary selectable bubble. + */ +const SHORT_USER_TEXT: string = '这段代码为什么会有内存泄漏?'; + +/** + * A message long enough to be cut by the line rule: eleven lines against the + * frozen eight. + */ +const LONG_USER_TEXT: string = [ + '请按下面的清单完整处理这次改动:', + '1. 在会话时间线里定位用户消息的渲染路径;', + '2. 为长消息加截断预览,并在行尾追加可点开的入口;', + '3. 面板内要能长按选择并复制,手柄可以调整范围;', + '4. 截断态气泡不给手柄,长按弹出轻量菜单;', + '5. 复制成功后给出状态提示,不要静默失败;', + '6. 浅色与深色主题都要检查对比度;', + '7. 竖屏与平板横屏两种布局都要走一遍;', + '8. 流式输出期间允许选中态被重置,但不能崩溃;', + '9. 交付前补单测并跑一遍架构检查;', + '10. 最后把设备证据截图归档。' +].join('\n'); + +/** + * A single-line message long enough to be cut by the character rule: the + * preview stops at 360 characters even though the message has one line. It has + * to stay above that count with the preview still measurably shorter, which is + * what makes the character branch distinguishable from the line branch in one + * transcript. + */ +const LONG_SINGLE_LINE_TEXT: string = [ + '下面这一整段是一条没有换行的长提示,用来验证按字符截断的分支:', + '它必须在预览里停在第三百六十个字符处并追加省略号,', + '同时行尾仍然要有可以点开的入口,点开之后面板里要显示完整的这一段内容,', + '面板内长按要能出现系统选择菜单与手柄,选择范围可以拖到整段文字之外,', + '复制全文时写入剪贴板的必须是完整文本而不是这段预览,', + '所以完整文本长度一定要明显大于预览长度,这样设备的回读报文才能把两者区分开,', + '这一条同样要覆盖手柄拖动的边界:从一个字符切到整段,再从整段缩回一个词,', + '手柄在长文里滑过折行位置时不能丢焦点,也不能把选中高亮留在已经离开的段落上,', + '如果预览行的最后一个字符正好落在气泡右边缘,追加的省略号与入口仍然必须完整可见,', + '浅色主题下入口用信息色加下划线,深色主题下同一个色板要保持可读而不刺眼,', + '这条提示后面还要继续补充一些内容,让完整文本长度明显大于三百六十个字符的预览长度,', + '否则回读到的长度数字不足以区分“复制全文”写的是整条消息还是这段预览,', + '所以这里再补两句:第一句确认字符截断以后仍然可以继续长按选择,', + '第二句确认这条消息在平板横屏与竖屏两种布局下都不会把入口挤出气泡,', + '最后还要确认这一条消息在深色主题下同样可读、截断标记与链接颜色不互相干扰。' +].join(''); + +/** + * An answer carrying every block the renderer draws, in one message: the + * cross-block selection has to be able to run paragraph -> list -> code, and + * the table and the code block are the two children that own a horizontal + * `Scroll`, which is the interaction the container has to survive. + */ +const ASSISTANT_MARKDOWN: string = [ + '## 选择与复制验证', + '', + '第一段正文:**加粗**、*斜体*、`行内代码`、~~删除线~~,以及一个 [OpenBitFun 文档](https://example.com/docs) 链接。', + '', + '- 列表项 A:拖动手柄时选择应能越过这一项', + '- 列表项 B:块间拼接应保留换行', + '- [x] 已完成的复选项', + '- [ ] 未完成的复选项', + '', + '1. 有序项一', + '2. 有序项二', + '', + '> 引用块:引用段落也参与连续选择。', + '', + '```ts', + "const message = 'a code block line long enough to overflow the pane horizontally';", + 'console.log(message);', + '```', + '', + '| 列 A | 列 B | 列 C |', + '| --- | --- | --- |', + '| 单元格一 | 单元格二 | 单元格三 |', + '| 更长的单元格内容 | 更长的单元格内容 | 更长的单元格内容 |', + '', + '---', + '', + '结尾段落:用于确认分隔线之后仍可继续选择。' +].join('\n'); + +const ASSISTANT_SHORT_MARKDOWN: string = + '收到,我按清单逐条处理,先给出结论:截断与全文面板都需要自己的选择表面。'; + +function message(id: string, role: string, text: string): ChatMessage { + return { id, role, text, status: 'completed', timestamp: '0' }; +} + +function row(id: string, role: string, text: string): ObservableChatTimelineItem { + return new ObservableChatTimelineItem({ + id, + type: role === 'user' ? 'user_message' : 'assistant_message', + message: message(id, role, text), + isStreaming: false, + isFinalizing: false + }); +} + +function transcript(): ObservableChatTimelineItem[] { + return [ + row('short-user', 'user', SHORT_USER_TEXT), + row('markdown-answer', 'assistant', ASSISTANT_MARKDOWN), + row('long-user', 'user', LONG_USER_TEXT), + row('advice-answer', 'assistant', ASSISTANT_SHORT_MARKDOWN), + row('long-line-user', 'user', LONG_SINGLE_LINE_TEXT) + ]; +} + +/** + * The copy and selection surface, over one transcript that carries every shape + * the feature distinguishes: a short user message, an answer with all of the + * markdown blocks, a line-cut user message, and a character-cut user message. + * + * A device check needs more than the pixels: whether the long press reached the + * system selection menu, what the truncated bubble opened, and which clipboard + * flavors a copy wrote are all invisible in a screenshot of the surface itself. + * The strip above the conversation therefore states what the last intent asked + * for and what the pasteboard actually holds afterwards, read back through the + * same pasteboard the copy wrote to. The last requested copy scope and the + * recorded mime types together are what proves the rich write really produced a + * second flavor rather than a plain text that merely looks right. + * + * The scenario id picks the mount, and nothing else, because the mount is the + * question: `copy-selection` renders the pane the way the wide detail leaf does, + * `copy-selection-nav` pushes it through a `Navigation` the way the compact + * route does, and `copy-selection-wide` forces the floating composer. The + * placement and the composer presentation follow the real window width, so the + * same fixture covers a phone and a tablet without a second scenario. + */ +@ComponentV2 +export struct CopySelectionPreview { + @Local state: ConversationViewState = new ConversationViewState(); + @Local placement: SettingsPlacement = + SettingsPlacementPolicy.compactBottom(SettingsSheetKind.UserMessageFullText); + @Local apiLevel: number = 0; + @Local crossBlock: boolean = false; + @Local lastIntent: string = '(none)'; + @Local clipboardReport: string = '(not read)'; + @Local viewportWidth: number = 0; + private readonly clipboard: ClipboardService = new ClipboardService(); + private readonly navigationStack: NavPathStack = new NavPathStack(); + private routed: boolean = false; + + aboutToAppear(): void { + this.state.surface = ChatSurface.Remote; + this.state.connectionState = 'connected'; + this.state.activeSession = { + sessionId: 'copy-selection-preview', + title: 'Copy and selection', + workspacePath: '/workspace', + agentType: 'code' + }; + this.state.desktopName = 'Studio'; + this.state.composerCapabilities = REMOTE_CHAT_COMPOSER_CAPABILITIES; + this.state.timelineItems = transcript(); + this.state.timelineRevision = 1; + this.apiLevel = DeviceApiLevelService.sdkApiVersion(); + this.crossBlock = SelectionCapabilityPolicy.supportsCrossBlockSelection(this.apiLevel); + if (this.scenarioId() === 'copy-selection-nav' && !this.routed) { + // Pushed rather than mounted as the navigation bar's own content: the + // compact shell opens a conversation as a destination on its stack. + this.routed = true; + this.navigationStack.pushPath({ name: 'copy-selection' }); + } + } + + private scenarioId(): string { + return AppStorage.get('scenarioId') ?? 'copy-selection'; + } + + private isWideViewport(): boolean { + return this.viewportWidth >= ConversationLayoutPolicy.MD_MIN_WIDTH; + } + + /** + * The placement the app root would resolve for this window. The fixture has + * no creases to offer and is not asked to reproduce a foldable, so the input + * carries the measured viewport and nothing else. + */ + private refreshGeometry(width: number, height: number): void { + this.viewportWidth = width; + this.placement = SettingsPlacementPolicy.resolve( + new AdaptiveLayoutInput(width, height, 0, false, false, false, this.isWideViewport(), [], []), + SettingsSheetKind.UserMessageFullText + ); + } + + private options(): ConversationViewOptions { + const options = new ConversationViewOptions(); + options.showSidebarButton = true; + options.showBackButton = false; + options.composerPresentation = + this.isWideViewport() ? ComposerPresentation.Floating : ComposerPresentation.Compact; + options.fullTextSheetPlacement = this.placement; + return options; + } + + private async handleIntent(intent: ConversationIntent): Promise { + if (intent.type === ConversationIntentType.CopyMessage) { + await this.copyWithScope(intent.value, intent.copyScope); + return; + } + if (intent.type === ConversationIntentType.OpenFilePreview) { + // The link span sits inside the selection container; recording the intent + // is what proves the tap still reached it. + this.lastIntent = `open-link ${intent.value}`; + return; + } + if (intent.type === ConversationIntentType.ChatInputChanged) { + this.state.chatInput = intent.value; + return; + } + this.lastIntent = `intent ${intent.type}`; + } + + /** + * The production clipboard write for one scope. The controller does this on a + * paired host; the preview drives the same services directly so the pasteboard + * really holds what a copy wrote. + */ + private async copyWithScope(text: string, scope: MessageCopyScope): Promise { + try { + if (scope === MessageCopyScope.Rich) { + await this.clipboard.copyRich(text, MarkdownHtmlSerializer.toHtml(text)); + } else { + await this.clipboard.writeText(text); + } + this.lastIntent = `copy ${scope} len=${text.length}`; + } catch (err) { + this.lastIntent = `copy ${scope} failed ${JSON.stringify(err)}`; + return; + } + await this.readClipboard(); + } + + /** + * What the pasteboard holds now: the mime types of every record with its + * length, and the primary record's first characters. A rich copy that only + * stated its scope would not be evidence that the html record was written, so + * the readback is what turns one tap into a payload report. + * + * The text is read per record rather than through `PasteData.getPrimaryText()`: + * measured on device, that accessor returns nothing here, so the primary record + * is the first record exactly as the write ordered it. + * + * Measured on the emulator, this readback is not evidence of the payload: the + * platform hands a plain app an empty `PasteData` (no records, no text) even + * while the copy is in the clipboard, and a package without the pasteboard read + * permission cannot go further. The strip therefore reports what the app is + * given, and the authoritative payload read stays + * `hidumper -s PasteboardService -a "--data"`, which states the record count + * and the mime types the copy wrote. + */ + private async readClipboard(): Promise { + try { + const data = await pasteboard.getSystemPasteboard().getData(); + const count = data.getRecordCount(); + const types: string[] = []; + let primaryText: string = ''; + for (let index = 0; index < count; index += 1) { + const record = data.getRecord(index); + const text = String(record.toPlainText()); + if (index === 0) { + primaryText = text; + } + types.push(`${record.mimeType}(${text.length})`); + } + const head = primaryText.substring(0, 40).replace(/\n/g, ' '); + this.clipboardReport = `records=${types.join(',')} plainLen=${primaryText.length} head="${head}"`; + } catch (err) { + const failure = err as BusinessError; + this.clipboardReport = `read refused code=${failure?.code} message=${failure?.message}`; + } + } + + build() { + Column() { + this.Diagnostics() + // The pane takes what is left, not the window's full height: the chat page + // puts its own overlay at the bottom of whatever box it is given, so a pane + // measured at the window's height draws its composer below the screen. + Column() { + if (this.scenarioId() === 'copy-selection-nav') { + this.NavigationLayout() + } else { + this.Conversation() + } + } + .width('100%') + .layoutWeight(1) + } + .width('100%') + .height('100%') + .backgroundColor(PAGE_BG) + .onAreaChange((_oldArea: Area, newArea: Area) => { + this.refreshGeometry( + WideLayoutGeometry.areaLength(newArea.width), + WideLayoutGeometry.areaLength(newArea.height) + ); + }) + } + + /** + * The compact route's own shape: a `Navigation` whose stack holds the + * conversation as a pushed destination. This exists because a sheet bound + * inside that subtree is the one placement the app has never used. + */ + @Builder + private NavigationLayout() { + Navigation(this.navigationStack) { + Column() + .width('100%') + .height('100%') + } + .width('100%') + .height('100%') + .hideTitleBar(true) + .mode(NavigationMode.Stack) + .navBarWidth('100%') + .minContentWidth(0) + .navDestination(this.Destination) + } + + @Builder + private Destination(name: string) { + NavDestination() { + this.Conversation() + } + .hideTitleBar(true) + .backgroundColor(PAGE_BG) + } + + @Builder + private Conversation() { + ConversationView({ + state: this.state, + options: this.options(), + onIntent: (intent: ConversationIntent): void => { + void this.handleIntent(intent); + } + }) + } + + /** + * The report strip. It lives above the pane rather than inside it so that + * nothing it draws can be mistaken for the surface under test. + */ + @Builder + private Diagnostics() { + Column({ space: 2 }) { + Text(`scenario=${this.scenarioId()} api=${this.apiLevel} crossBlock=${this.crossBlock} ` + + `sheet=${this.placement.mode} w=${Math.round(this.viewportWidth)}`) + .id('copy-selection-scenario') + .fontSize(10) + .fontColor(MUTED) + .width('100%') + Text(`intent=${this.lastIntent}`) + .id('copy-selection-intent') + .fontSize(10) + .fontColor(INK) + .width('100%') + .maxLines(2) + Text(`clip=${this.clipboardReport}`) + .id('copy-selection-clipboard') + .fontSize(10) + .fontColor(INK) + .width('100%') + .maxLines(3) + } + .width('100%') + // The shell is immersive, so the strip starts at the window's top edge and + // has to clear the status bar itself; without the inset the clock draws over + // the report it is there to be read against. + .padding({ left: 12, right: 12, top: 48, bottom: 6 }) + .backgroundColor(SOFT) + .border({ width: { bottom: 0.5 }, color: LINE }) + } +} 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'); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets index c9faa592dd..8da4cd02c6 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/MobileDesignGallery.ets @@ -1,6 +1,7 @@ import { HistoryScrollPreview } from './HistoryScrollPreview'; import { ColdStartHomePreview } from './ColdStartHomePreview'; import { ComposerSubmissionPreview } from './ComposerSubmissionPreview'; +import { CopySelectionPreview } from './CopySelectionPreview'; import { DurableTimelinePreview } from './DurableTimelinePreview'; import { DeviceSelectorPreview } from './DeviceSelectorPreview'; import { CatalogRefreshPreview } from './CatalogRefreshPreview'; @@ -68,6 +69,8 @@ struct MobileDesignGallery { DeviceSelectorPreview() } else if (AppStorage.get('scenarioId') === 'welcome-home') { WelcomeHome() + } else if ((AppStorage.get('scenarioId') || '').startsWith('copy-selection')) { + CopySelectionPreview() } else if ((AppStorage.get('scenarioId') || '').startsWith('interaction-mailbox')) { InteractionMailboxPreview() } else { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 23f661bbb3..6d13f2fd08 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets @@ -53,7 +53,7 @@ import { AsyncLifecycleGate } from '../../services/AsyncLifecycleGate'; import { RemoteUiState } from '../../services/RemoteUiState'; import { VoiceInputLifecycleController } from '../../services/VoiceInputLifecycleController'; import { VoiceInputService } from '../../services/VoiceInputService'; -import { ConversationIntent } from '../actions/ConversationIntent'; +import { ConversationIntent, MessageCopyScope } from '../actions/ConversationIntent'; import { AppRootHostPort } from '../host/AppRootHostAdapter'; import { AppRootPresentationActions } from '../actions/AppRootPresentationActions'; import { @@ -779,7 +779,9 @@ export abstract class AppRootRuntimeComposition { rename: async (title: string): Promise => { await this.conversationController.renameVisibleSession(title); }, - copy: async (text: string): Promise => { await this.conversationController.copyRemoteMessage(text); }, + copy: async (text: string, scope: MessageCopyScope): Promise => { + await this.conversationController.copyRemoteMessage(text, scope); + }, retry: async (text: string): Promise => { await this.conversationController.retryVisibleMessage(text); }, selectModel: async (id: string): Promise => { await this.conversationController.selectVisibleModel(id); }, pickImages: async (): Promise => { await this.pickImages(); }, @@ -993,7 +995,7 @@ export abstract class AppRootRuntimeComposition { } refreshLocalizedRuntimeCopy(): void { - this.remotePageState.setStatusText(RemoteI18n.retranslateKnown( + this.remotePageState.setStatusText(RemoteI18n.retranslate(RemoteI18n.retranslateKnown( this.remotePageState.statusText, [ ConnectionStatusPresenter.labelKey(this.remotePageState.connectionState), @@ -1001,10 +1003,12 @@ export abstract class AppRootRuntimeComposition { 'status.waitingConnection', 'status.notConnected' ] - )); + ))); this.remotePageState.setError(RemoteI18n.retranslate(this.remotePageState.sessionErrorText)); this.remotePageState.setWorkspace( - RemoteI18n.retranslateKnown(this.remotePageState.workspaceName, ['status.notConnected']), + RemoteI18n.retranslateWorkspaceName( + this.remotePageState.workspaceName, this.remotePageState.workspacePath, this.remotePageState.workspaceId + ), this.remotePageState.workspacePath, this.remotePageState.assistantId, this.remotePageState.workspaceBranch, diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets index 71f8762672..fdec3a1adf 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/ConversationController.ets @@ -8,6 +8,7 @@ import { ChatTimelineItem } from '../../model/ChatTimelineModels'; import { VoiceInputRouteSnapshot } from '../../services/VoiceInputLifecycleController'; import { AppRootRouteState } from '../navigation/AppRootRouteState'; import { AppRoute, AppRouteContract } from '../navigation/AppRouteContract'; +import { MessageCopyScope } from '../actions/ConversationIntent'; import { GeneralChatPageState } from '../state/GeneralChatPageState'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; @@ -137,7 +138,9 @@ export class ConversationController { } async stopRemoteTask(): Promise { await this.transcript.stopRemoteTask(); } async renameRemoteSession(title: string): Promise { await this.transcript.renameRemoteSession(title); } - async copyRemoteMessage(text: string): Promise { await this.transcript.copyRemoteMessage(text); } + async copyRemoteMessage(text: string, scope: MessageCopyScope): Promise { + await this.transcript.copyRemoteMessage(text, scope); + } async downloadRemoteFile(path: string): Promise { await this.transcript.downloadRemoteFile(path); } retryRemoteMessage(text: string): void { this.transcript.retryRemoteMessage(text); } async approveRemoteTool(toolId: string, updatedInput?: Object): Promise { diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets index 4eb2940da0..df2a3d79df 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/viewmodel/RemoteTranscriptController.ets @@ -8,10 +8,12 @@ import { ChatTimelineItem } from '../../model/ChatTimelineModels'; import { ChatTimelineState, ChatTimelineStore } from '../../services/ChatTimelineStore'; import { ConnectionErrorPolicy } from '../../services/ConnectionErrorPolicy'; import { Encoding } from '../../services/Encoding'; +import { MarkdownHtmlSerializer } from '../../services/MarkdownHtmlSerializer'; import { RemoteChatPollingCursor, RemoteChatPollingSnapshot } from '../../services/RemoteChatPollingLifecycleController'; import { RemoteI18n } from '../../i18n/RemoteI18n'; import { RemoteLogger } from '../../services/RemoteLogger'; import { RemoteUiState } from '../../services/RemoteUiState'; +import { MessageCopyScope } from '../actions/ConversationIntent'; import { RemotePageState } from '../state/RemotePageState'; import { RemoteConversationDependencies, @@ -228,12 +230,26 @@ export class RemoteTranscriptController { ); } - async copyRemoteMessage(text: string): Promise { + /** + * Writes one message body to the clipboard. + * + * `rich` is the only scope that changes the payload shape: the Markdown source + * goes out as the plain flavor and its rendered html as a second record, so a + * rich-text receiver pastes formatted content while everything else still + * pastes the source. The other scopes stay the single plain write that every + * copy caller used before the assistant menu. + */ + async copyRemoteMessage(text: string, scope: MessageCopyScope): Promise { if (text.trim().length === 0) { return; } try { - await requireRemoteRuntime(this.remoteRuntime).clipboard.writeText(text); + const clipboard = requireRemoteRuntime(this.remoteRuntime).clipboard; + if (scope === MessageCopyScope.Rich) { + await clipboard.copyRich(text, MarkdownHtmlSerializer.toHtml(text)); + } else { + await clipboard.writeText(text); + } this.remote.setStatusText(RemoteI18n.t('status.messageCopied')); } catch (err) { this.remote.setStatusText(ConnectionErrorPolicy.errorText(err)); diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ClipboardService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ClipboardService.ets index de0da89e6d..e1aa25433f 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/ClipboardService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/ClipboardService.ets @@ -1,6 +1,46 @@ import pasteboard from '@ohos.pasteboard'; import { OhosError } from './OhosError'; +/** + * One pasteboard record as it will be written: a MIME flavor and its payload. + * + * The list order is the contract, not an implementation detail — the first + * record is the flavor the pasteboard treats as primary, so plain text has to + * come first for receivers that cannot read html. + */ +export interface ClipboardRecordSpec { + mimeType: string; + value: string; +} + +/** + * Rich clipboard payload construction, kept free of the pasteboard kit so the + * record types and their order stay unit-testable on the host runner. + */ +export class ClipboardRichPayload { + static readonly MIMETYPE_TEXT_PLAIN: string = 'text/plain'; + static readonly MIMETYPE_TEXT_HTML: string = 'text/html'; + + /** + * Markdown source as the plain flavor plus its rendered html as a second + * flavor: html-capable receivers paste formatted content, everything else + * pastes the markdown source. + * + * An empty html adds no record at all, which makes that case identical to a + * plain write. An empty plain keeps its primary slot so the html flavor can + * never become the only thing a receiver can read. + */ + static build(plainText: string, html: string): ClipboardRecordSpec[] { + const specs: ClipboardRecordSpec[] = [ + { mimeType: ClipboardRichPayload.MIMETYPE_TEXT_PLAIN, value: plainText }, + ]; + if (html.length > 0) { + specs.push({ mimeType: ClipboardRichPayload.MIMETYPE_TEXT_HTML, value: html }); + } + return specs; + } +} + export class ClipboardService { async writeText(text: string): Promise { try { @@ -11,4 +51,23 @@ export class ClipboardService { } } + /** + * Writes both flavors in one operation: the markdown source as the primary + * plain record and the rendered html as an additional record. Use `writeText` + * for a plain-only copy. + */ + async copyRich(plainText: string, html: string): Promise { + try { + const specs = ClipboardRichPayload.build(plainText, html); + const primary = specs[0]; + const data = pasteboard.createData(primary.mimeType, primary.value); + for (let index = 1; index < specs.length; index += 1) { + const spec = specs[index]; + data.addRecord(pasteboard.createRecord(spec.mimeType, spec.value)); + } + await pasteboard.getSystemPasteboard().setData(data); + } catch (err) { + throw OhosError.wrap('clipboard rich write', err); + } + } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceApiLevelService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceApiLevelService.ets new file mode 100644 index 0000000000..961a7e0daa --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceApiLevelService.ets @@ -0,0 +1,19 @@ +import { deviceInfo } from '@kit.BasicServicesKit'; + +/** + * The device's reported API level, for presentation that must not read a + * platform kit itself. + * + * A failed read is reported as 0, which no capability gate treats as a + * supported level, so an unmeasurable device keeps the conservative path + * instead of being assumed capable. + */ +export class DeviceApiLevelService { + static sdkApiVersion(): number { + try { + return deviceInfo.sdkApiVersion; + } catch (_err) { + return 0; + } + } +} diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownHtmlSerializer.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownHtmlSerializer.ets new file mode 100644 index 0000000000..94b2f86647 --- /dev/null +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownHtmlSerializer.ets @@ -0,0 +1,262 @@ +import { + MarkdownParser, + ParsedMarkdownBlock, + ParsedMarkdownInline, + ParsedMarkdownListItem, + ParsedMarkdownTableCell, + ParsedMarkdownTableRow +} from './MarkdownParser'; + +const SAFE_LINK_SCHEME: RegExp = /^(https?:|computer:|file:)/i; +const ORDERED_MARKER: RegExp = /^\d+\.$/; +const CHECKED_PREFIX: string = ' '; +const UNCHECKED_PREFIX: string = ' '; + +/** One open list element while a parsed list block is being serialized. */ +class HtmlListLevel { + depth: number = 0; + ordered: boolean = false; + itemOpen: boolean = false; + + constructor(depth: number, ordered: boolean) { + this.depth = depth; + this.ordered = ordered; + } +} + +/** + * Renders the parsed Markdown tree as the HTML flavor of a rich clipboard + * payload. Pure string work over `ParsedMarkdownBlock[]`: it never touches the + * UI, the pasteboard, or a platform kit, so the host runner can exercise the + * real source and the clipboard owner only has to move the string. + * + * Every text node and attribute value is escaped, and links are limited to an + * allowlisted scheme so a hostile document cannot smuggle markup or a script + * URL into whatever receives the paste. + */ +export class MarkdownHtmlSerializer { + /** Parses `text` and serializes it. Blank input produces an empty payload. */ + static toHtml(text: string): string { + if (text.trim().length === 0) { + return ''; + } + return MarkdownHtmlSerializer.blocksToHtml(MarkdownParser.parse(text)); + } + + static blocksToHtml(blocks: ParsedMarkdownBlock[]): string { + let html = ''; + for (let index = 0; index < blocks.length; index += 1) { + html += MarkdownHtmlSerializer.blockHtml(blocks[index]); + } + return html; + } + + private static blockHtml(block: ParsedMarkdownBlock): string { + if (block.type === 'heading') { + return MarkdownHtmlSerializer.headingHtml(block); + } + if (block.type === 'quote') { + return MarkdownHtmlSerializer.quoteHtml(block); + } + if (block.type === 'list') { + return MarkdownHtmlSerializer.listHtml(block); + } + if (block.type === 'table') { + return MarkdownHtmlSerializer.tableHtml(block); + } + if (block.type === 'code') { + return MarkdownHtmlSerializer.codeHtml(block); + } + if (block.type === 'divider') { + return '
'; + } + if (block.type === 'frontmatter') { + return `
${MarkdownHtmlSerializer.escape(block.text)}
`; + } + return MarkdownHtmlSerializer.paragraphHtml(block); + } + + private static headingHtml(block: ParsedMarkdownBlock): string { + const level = block.level < 1 ? 1 : (block.level > 6 ? 6 : block.level); + const content = MarkdownHtmlSerializer.inlineHtml(block.inlines, block.text); + return `${content}`; + } + + private static paragraphHtml(block: ParsedMarkdownBlock): string { + const content = MarkdownHtmlSerializer.inlineHtml(block.inlines, block.text); + if (content.length === 0) { + return ''; + } + return `

${content}

`; + } + + /** + * The parser reports the quote depth it consumed, so a nested quote becomes a + * nested element rather than a repeated sibling. + */ + private static quoteHtml(block: ParsedMarkdownBlock): string { + const level = block.level < 1 ? 1 : block.level; + const content = MarkdownHtmlSerializer.inlineHtml(block.inlines, block.text); + return '
'.repeat(level) + content + '
'.repeat(level); + } + + private static codeHtml(block: ParsedMarkdownBlock): string { + const body = MarkdownHtmlSerializer.escape(block.text); + if (block.language.length === 0) { + return `
${body}
`; + } + const language = MarkdownHtmlSerializer.escape(block.language); + return `
${body}
`; + } + + /** + * List items carry their own depth and marker, so one flat sequence still + * renders as nested `ul`/`ol` and swings between both list elements when the + * source mixes markers at the same level. + */ + private static listHtml(block: ParsedMarkdownBlock): string { + const items = block.items; + if (items.length === 0) { + return ''; + } + const levels: HtmlListLevel[] = []; + let html = ''; + for (let index = 0; index < items.length; index += 1) { + const item = items[index]; + const depth = item.depth > 0 ? item.depth : 0; + const ordered = ORDERED_MARKER.test(item.marker); + + while (levels.length > depth + 1) { + html += MarkdownHtmlSerializer.closeLevel(levels); + } + if (levels.length === 0) { + html += MarkdownHtmlSerializer.openLevel(levels, depth, ordered); + } else if (levels[levels.length - 1].depth < depth) { + while (levels.length < depth + 1) { + html += MarkdownHtmlSerializer.openLevel(levels, levels.length, ordered); + } + } else if (levels[levels.length - 1].ordered !== ordered) { + html += MarkdownHtmlSerializer.closeLevel(levels); + html += MarkdownHtmlSerializer.openLevel(levels, depth, ordered); + } + + const level = levels[levels.length - 1]; + if (level.itemOpen) { + html += ''; + level.itemOpen = false; + } + level.itemOpen = true; + html += '
  • '; + html += MarkdownHtmlSerializer.listItemContent(item); + } + while (levels.length > 0) { + html += MarkdownHtmlSerializer.closeLevel(levels); + } + return html; + } + + private static listItemContent(item: ParsedMarkdownListItem): string { + const content = MarkdownHtmlSerializer.inlineHtml(item.inlines, item.text); + if (item.checked === 1) { + return CHECKED_PREFIX + content; + } + if (item.checked === 0) { + return UNCHECKED_PREFIX + content; + } + return content; + } + + private static openLevel(levels: HtmlListLevel[], depth: number, ordered: boolean): string { + levels[levels.length] = new HtmlListLevel(depth, ordered); + return ordered ? '
      ' : '
        '; + } + + private static closeLevel(levels: HtmlListLevel[]): string { + const level = levels[levels.length - 1]; + let html = ''; + if (level.itemOpen) { + html += ''; + level.itemOpen = false; + } + html += level.ordered ? '
    ' : ''; + levels.splice(levels.length - 1, 1); + return html; + } + + private static tableHtml(block: ParsedMarkdownBlock): string { + const rows = block.tableRows; + let head = ''; + let body = ''; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row.header) { + head += MarkdownHtmlSerializer.tableRowHtml(row); + } else { + body += MarkdownHtmlSerializer.tableRowHtml(row); + } + } + return `${head}${body}
    `; + } + + private static tableRowHtml(row: ParsedMarkdownTableRow): string { + const tag = row.header ? 'th' : 'td'; + let cells = ''; + for (let index = 0; index < row.cells.length; index += 1) { + const cell: ParsedMarkdownTableCell = row.cells[index]; + const style = MarkdownHtmlSerializer.alignmentStyle(cell.alignment); + const content = MarkdownHtmlSerializer.inlineHtml(cell.inlines, cell.text); + cells += `<${tag}${style}>${content}`; + } + return `${cells}`; + } + + private static alignmentStyle(alignment: string): string { + if (alignment === 'center' || alignment === 'right') { + return ` style="text-align: ${alignment}"`; + } + return ''; + } + + private static inlineHtml(inlines: ParsedMarkdownInline[], fallback: string): string { + if (inlines.length === 0) { + return MarkdownHtmlSerializer.escape(fallback); + } + let html = ''; + for (let index = 0; index < inlines.length; index += 1) { + html += MarkdownHtmlSerializer.inlineNodeHtml(inlines[index]); + } + return html; + } + + private static inlineNodeHtml(inline: ParsedMarkdownInline): string { + const text = MarkdownHtmlSerializer.escape(inline.text); + if (inline.type === 'strong') { + return `${text}`; + } + if (inline.type === 'emphasis') { + return `${text}`; + } + if (inline.type === 'code') { + return `${text}`; + } + if (inline.type === 'delete') { + return `${text}`; + } + if (inline.type === 'link') { + if (!SAFE_LINK_SCHEME.test(inline.url)) { + return text; + } + return `${text}`; + } + return text; + } + + /** Escapes `&`, `<`, `>` and `"` for both text nodes and attribute values. */ + private static escape(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } +} 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..d4f8b65918 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,98 +2,281 @@ 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. */ 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. - } - } - - 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 { + // 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) { - onChange({ top: 0, bottom: 0 }); + 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 => {}; } } + 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. + // + // `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), + 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 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 + }; + } +} + +/** + * 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); } - const navigation = appWindow.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR).bottomRect.height; - if (navigation > 0) { - return { top, bottom: navigation }; + 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 => { + // observeInsets publishes before its promise resolves, including its + // zero-inset failure fallback. Reject stale data before touching the + // shared cache or listeners; the cleanup guard below runs too late. + if (generation !== this.generation || this.listeners.length === 0) { + return; + } + 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'); } - // 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 }; } } +/** The process-wide instance every `WindowInsetsBinding` registers with. */ +const sharedInsetsBroadcast: InsetsBroadcast = new InsetsBroadcast(); + /** * One component's live view of the window insets, in vp. * @@ -101,35 +284,46 @@ 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. - * 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. + * 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 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 { @Trace top: number = 0; @Trace bottom: number = 0; + @Trace left: number = 0; + @Trace right: number = 0; private subscription?: WindowInsetSubscription; private disposed: boolean = false; 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; } this.top = uiContext.px2vp(insets.top); this.bottom = uiContext.px2vp(insets.bottom); - RemoteLogger.info(`window insets top=${this.top} bottom=${this.bottom}`); - }).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; - }); + 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}`); + }; + // 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 { @@ -149,6 +343,20 @@ 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 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/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" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index 3e30be5306..c82f5680c0 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json @@ -2248,6 +2248,26 @@ "name": "chat_analyzeImageDefault", "value": "Please analyze this image." }, + { + "name": "chat_fullText", + "value": "Full text" + }, + { + "name": "chat_copyFullText", + "value": "Copy full text" + }, + { + "name": "chat_copyRich", + "value": "Copy (rich text)" + }, + { + "name": "chat_copyMarkdown", + "value": "Copy Markdown" + }, + { + "name": "chat_copyPlain", + "value": "Copy plain text" + }, { "name": "status_waitingConnection", "value": "Waiting to connect" diff --git a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json index 3fb4ad32dc..93abf78ed1 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json +++ b/src/apps/mobile/harmonyos/entry/src/main/resources/zh_CN/element/string.json @@ -2248,6 +2248,26 @@ "name": "chat_analyzeImageDefault", "value": "请分析这张图片。" }, + { + "name": "chat_fullText", + "value": "全文" + }, + { + "name": "chat_copyFullText", + "value": "复制全文" + }, + { + "name": "chat_copyRich", + "value": "复制(富文本)" + }, + { + "name": "chat_copyMarkdown", + "value": "复制 Markdown" + }, + { + "name": "chat_copyPlain", + "value": "复制纯文本" + }, { "name": "status_waitingConnection", "value": "等待连接" diff --git a/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets index 569ad12c3a..fdd3547c22 100644 --- a/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets +++ b/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets @@ -1,6 +1,8 @@ +import { util } from '@kit.ArkTS'; import { describe, expect, it } from '@ohos/hypium'; import { AppLocale, APP_LOCALE_EN_US, APP_LOCALE_ZH_CN } from '../main/ets/i18n/AppLocale'; import { RemoteI18n } from '../main/ets/i18n/RemoteI18n'; +import { HostTextRetranslator } from '../main/ets/i18n/HostTextRetranslator'; import { ConnectionErrorPolicy } from '../main/ets/services/ConnectionErrorPolicy'; import { GeneralChatServiceState, @@ -8,6 +10,15 @@ import { } from '../main/ets/services/general-chat/GeneralChatServiceState'; import { TimeFormat } from '../main/ets/services/TimeFormat'; +interface RawStringEntry { + name: string; + value: string; +} + +interface RawStringCatalog { + string: RawStringEntry[]; +} + function restoreZhCn(): void { RemoteI18n.setLanguage(APP_LOCALE_ZH_CN); } @@ -109,5 +120,53 @@ export default function i18nUnitTest() { expect(RemoteI18n.speechLocate()).assertEqual('US'); restoreZhCn(); }); + + it('maps dotted keys to underscore resource names', 0, () => { + restoreZhCn(); + expect(RemoteI18n.t('workspaceTools.browse')).assertEqual('浏览受控设备文件夹'); + expect(RemoteI18n.t('settings.account.section')).assertEqual('账号'); + expect(RemoteI18n.t('settings.general.section')).assertEqual('通用'); + expect(RemoteI18n.t('remote.title')).assertEqual('远程'); + expect(RemoteI18n.t('sidebar.moreSessions')).assertEqual('还有 %1$s 个会话'); + }); + + it('keeps generated rawfile catalogs in key lockstep', 0, () => { + restoreZhCn(); + const bytes = getContext(RemoteI18n).resourceManager.getRawFileContentSync('string.json'); + const catalog = JSON.parse(util.TextDecoder.create('utf-8').decodeWithStream(bytes)) as RawStringCatalog; + const enKeys = catalog.string.filter((entry: RawStringEntry) => !entry.name.endsWith('.zh')) + .map((entry: RawStringEntry) => entry.name).sort(); + const zhKeys = catalog.string.filter((entry: RawStringEntry) => entry.name.endsWith('.zh')) + .map((entry: RawStringEntry) => entry.name.slice(0, -3)).sort(); + expect(zhKeys.join('\n')).assertEqual(enKeys.join('\n')); + expect(zhKeys.length > 0).assertTrue(); + expect(zhKeys.indexOf('settings_title') >= 0).assertTrue(); + expect(zhKeys.indexOf('workspaceTools_browse') >= 0).assertTrue(); + expect(zhKeys.indexOf('sidebar_moreSessions') >= 0).assertTrue(); + }); + }); + + describe('HostTextRetranslator', () => { + it('renders exact and templated messages in the requested language', 0, () => { + const chinese = new HostTextRetranslator(APP_LOCALE_ZH_CN); + const english = new HostTextRetranslator(APP_LOCALE_EN_US); + expect(chinese.retranslate('Back')).assertEqual('返回'); + expect(chinese.retranslate('返回')).assertEqual('返回'); + expect(english.retranslate('返回')).assertEqual('Back'); + expect(english.retranslate('Back')).assertEqual('Back'); + expect(chinese.retranslate('Downloaded a.txt · 3 KB')).assertEqual('下载完成 a.txt · 3 KB'); + expect(chinese.retranslate('下载完成 a.txt · 3 KB')).assertEqual('下载完成 a.txt · 3 KB'); + expect(chinese.retranslate('Show 3 more sessions')).assertEqual('再显示 3 条会话'); + expect(english.retranslate('正在切换到 Desk-1…')).assertEqual('Switching to Desk-1…'); + }); + + it('returns original message when no match found', 0, () => { + restoreZhCn(); + const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); + const unknown = 'This message does not exist in any catalog'; + expect(retranslator.retranslate(unknown)).assertEqual(unknown); + const empty = ''; + expect(retranslator.retranslate(empty)).assertEqual(empty); + }); }); } diff --git a/src/apps/mobile/harmonyos/i18n/generate.cjs b/src/apps/mobile/harmonyos/i18n/generate.cjs new file mode 100644 index 0000000000..d1eaec2472 --- /dev/null +++ b/src/apps/mobile/harmonyos/i18n/generate.cjs @@ -0,0 +1,71 @@ +// Build-time i18n rawfile catalog; generated resources are not edited. +// +// Merges the en_US and zh_CN element string catalogs into the single +// rawfile document HostTextRetranslator reads: English entries keep the +// resource name as-is, Chinese entries gain a ".zh" suffix that +// HostTextRetranslator.splitEntryLanguage recognizes as the language tag. +const fs = require('node:fs'); +const path = require('node:path'); + +const resources = path.resolve(__dirname, '../entry/src/main/resources'); +const output = path.join(resources, 'rawfile', 'string.json'); + +function readElementCatalog(locale) { + const file = path.join(resources, locale, 'element', 'string.json'); + const catalog = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!Array.isArray(catalog.string)) { + throw new Error(`Expected a "string" array in ${locale}/element/string.json`); + } + return catalog.string; +} + +// HostTextRetranslator.splitEntryLanguage treats a trailing dot segment of +// 2-3 lowercase letters as a language tag, so an English catalog name like +// "time.pm" would be misfiled as language "pm". HarmonyOS element names are +// [a-zA-Z0-9_] today; this guard keeps a future dotted name from silently +// corrupting the catalog instead of failing the build loudly. +function assertUntaggedEnglishNames(names) { + for (const name of names) { + const lastDot = name.lastIndexOf('.'); + if (lastDot < 0) { + continue; + } + const suffix = name.slice(lastDot + 1); + if (suffix.length >= 2 && suffix.length <= 3 && suffix.toLowerCase() === suffix) { + throw new Error(`English catalog name "${name}" would be parsed as language "${suffix}"; rename it`); + } + } +} + +function assertKeyLockstep(english, chinese) { + const englishNames = new Set(english.map((entry) => entry.name)); + const chineseNames = new Set(chinese.map((entry) => entry.name)); + for (const entry of english) { + if (!chineseNames.has(entry.name)) { + throw new Error(`en_US catalog key "${entry.name}" is missing from zh_CN`); + } + } + for (const entry of chinese) { + if (!englishNames.has(entry.name)) { + throw new Error(`zh_CN catalog key "${entry.name}" is missing from en_US`); + } + } +} + +function generate() { + const english = readElementCatalog('en_US'); + const chinese = readElementCatalog('zh_CN'); + assertKeyLockstep(english, chinese); + assertUntaggedEnglishNames(english.map((entry) => entry.name)); + const entries = english.concat( + chinese.map((entry) => ({ name: `${entry.name}.zh`, value: entry.value })) + ); + const contents = JSON.stringify({ string: entries }, null, 2) + '\n'; + fs.mkdirSync(path.dirname(output), { recursive: true }); + if (!fs.existsSync(output) || fs.readFileSync(output, 'utf8') !== contents) { + fs.writeFileSync(output, contents); + } +} + +module.exports = { generate }; +if (require.main === module) generate(); diff --git a/src/apps/mobile/harmonyos/tools/tests/assistant-message-copy-menu.test.cjs b/src/apps/mobile/harmonyos/tools/tests/assistant-message-copy-menu.test.cjs new file mode 100644 index 0000000000..f0dd741633 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/assistant-message-copy-menu.test.cjs @@ -0,0 +1,109 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// The assistant message's copy menu is composed from a policy list so each +// export scope is data. Exercise the list, the scope contract and the copy +// itself; the anchored popup's geometry, dismissal and the actual pasteboard +// write stay a device-level check. +function loadEts(file, resolveModule) { + const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, + } }).outputText; + const exported = {}; + new Function('require', 'exports', compiled)(resolveModule || (() => ({})), exported); + return exported; +} + +const etsRoot = path.join(__dirname, '../../entry/src/main/ets'); +const resourcesRoot = path.join(__dirname, '../../entry/src/main/resources'); + +// The scope enum is owned by the copy intent, which is the one value the policy +// imports. Load it through the same transpile pass instead of restating it. +const intent = loadEts(path.join(etsRoot, 'pages/actions/ConversationIntent.ets')); +const { MessageCopyScope } = intent; +const { AssistantMessageCopyAction, AssistantMessageCopyMenuPolicy } = loadEts( + path.join(etsRoot, 'pages/policy/AssistantMessageCopyMenuPolicy.ets'), + (specifier) => { + if (specifier === '../actions/ConversationIntent') { + return intent; + } + return {}; + } +); + +function catalog(locale) { + const source = fs.readFileSync(path.join(resourcesRoot, locale, 'element/string.json'), 'utf8'); + const entries = JSON.parse(source).string; + const values = new Map(); + for (const entry of entries) { + values.set(entry.name, entry.value); + } + return values; +} + +test('the copy menu offers the three export scopes in frozen order', () => { + const items = AssistantMessageCopyMenuPolicy.items(); + assert.equal(items.length, 3); + assert.deepEqual( + items.map((item) => item.scope), + [MessageCopyScope.Rich, MessageCopyScope.Markdown, MessageCopyScope.Plain] + ); + assert.deepEqual( + items.map((item) => item.labelKey), + ['chat.copyRich', 'chat.copyMarkdown', 'chat.copyPlain'] + ); +}); + +test('every menu item names a distinct action, so a second entry cannot collide', () => { + const items = AssistantMessageCopyMenuPolicy.items(); + const actions = items.map((item) => item.action); + assert.equal(new Set(actions).size, actions.length); + assert.deepEqual(actions, [ + AssistantMessageCopyAction.RichText, + AssistantMessageCopyAction.Markdown, + AssistantMessageCopyAction.PlainText + ]); +}); + +test('the scope contract the clipboard branches on is exactly rich, markdown and plain', () => { + assert.equal(MessageCopyScope.Rich, 'rich'); + assert.equal(MessageCopyScope.Markdown, 'markdown'); + assert.equal(MessageCopyScope.Plain, 'plain'); +}); + +test('each call hands back its own list', () => { + const first = AssistantMessageCopyMenuPolicy.items(); + first.push({ action: 'scratch', scope: MessageCopyScope.Rich, labelKey: 'scratch' }); + assert.equal(AssistantMessageCopyMenuPolicy.items().length, 3); +}); + +test('this package adds its copy to every locale catalog', () => { + const expected = { + chat_copyRich: { 'en_US': 'Copy (rich text)', 'zh_CN': '复制(富文本)' }, + chat_copyMarkdown: { 'en_US': 'Copy Markdown', 'zh_CN': '复制 Markdown' }, + chat_copyPlain: { 'en_US': 'Copy plain text', 'zh_CN': '复制纯文本' }, + }; + for (const [name, values] of Object.entries(expected)) { + for (const [locale, value] of Object.entries(values)) { + assert.equal(catalog(locale).get(name), value, `${name} in ${locale}`); + } + } +}); + +test('the menu labels resolve against the resource names the app asks for', () => { + for (const locale of ['en_US', 'zh_CN']) { + const values = catalog(locale); + for (const item of AssistantMessageCopyMenuPolicy.items()) { + const resourceName = item.labelKey.replace(/\./g, '_'); + assert.equal(values.has(resourceName), true, `${resourceName} in ${locale}`); + assert.ok(values.get(resourceName).length > 0, `${resourceName} in ${locale} is empty`); + } + } +}); + +test('the two locale catalogs stay in key lockstep', () => { + assert.equal(catalog('en_US').size, catalog('zh_CN').size); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/clipboard-rich-payload.test.cjs b/src/apps/mobile/harmonyos/tools/tests/clipboard-rich-payload.test.cjs new file mode 100644 index 0000000000..10d02500f7 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/clipboard-rich-payload.test.cjs @@ -0,0 +1,152 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// The rich copy path is exercised against a stubbed pasteboard kit so the +// documented plain-primary + html-record sequence is host-verifiable; device +// paste behavior stays a device-level check. +function loadEts(file, dependencies = {}) { + const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, + } }).outputText; + const exported = {}; + new Function('require', 'exports', compiled)(name => dependencies[name] || {}, exported); + return exported; +} + +const serviceDir = path.join(__dirname, '../../entry/src/main/ets/services'); +const { OhosError } = loadEts(path.join(serviceDir, 'OhosError.ets')); + +// Official flavor identifiers: createData takes the plain primary record and +// each extra flavor is added with addRecord(createRecord(...)). +function pasteboardStub(options = {}) { + const calls = { order: [], createData: [], createRecord: [], addRecord: [], setData: [] }; + const api = { + MIMETYPE_TEXT_PLAIN: 'text/plain', + MIMETYPE_TEXT_HTML: 'text/html', + createData(mimeType, value) { + calls.order.push('createData'); + calls.createData.push({ mimeType, value }); + if (options.createDataError) throw options.createDataError; + const records = []; + return { + records, + addRecord(record) { calls.order.push('addRecord'); records.push(record); calls.addRecord.push(record); return this; }, + }; + }, + createRecord(mimeType, value) { + calls.order.push('createRecord'); + const record = { mimeType, value }; + calls.createRecord.push(record); + return record; + }, + getSystemPasteboard() { + return { + async setData(data) { + calls.order.push('setData'); + calls.setData.push(data); + if (options.setDataError) throw options.setDataError; + }, + }; + }, + }; + return { api, calls }; +} + +function loadClipboard(stub) { + return loadEts(path.join(serviceDir, 'ClipboardService.ets'), { + '@ohos.pasteboard': { default: stub.api }, './OhosError': { OhosError }, + }); +} + +const MARKDOWN = '# Title\n\nPlain **markdown** source.\n'; +const HTML = '

    Title

    Plain markdown source.

    '; + +test('rich payload keeps the markdown plain record primary and appends html second', () => { + const { api, calls } = pasteboardStub(); + const { ClipboardRichPayload } = loadClipboard({ api, calls }); + const specs = ClipboardRichPayload.build(MARKDOWN, HTML); + assert.equal(specs.length, 2); + assert.deepEqual(specs[0], { mimeType: api.MIMETYPE_TEXT_PLAIN, value: MARKDOWN }); + assert.deepEqual(specs[1], { mimeType: api.MIMETYPE_TEXT_HTML, value: HTML }); + // Building a payload is pure: it must not touch the pasteboard. + assert.deepEqual(calls.order, []); +}); + +test('rich payload passes both flavors through verbatim', () => { + const { api } = pasteboardStub(); + const { ClipboardRichPayload } = loadClipboard({ api, calls: {} }); + const markdown = '# Heading\n\n| a | b |\n| - | - |\n\n```ts\nconst value = "<&>";\n```\n\n'; + const html = '
    const value = "<&>";
    '; + const specs = ClipboardRichPayload.build(markdown, html); + assert.strictEqual(specs[0].value, markdown); + assert.strictEqual(specs[1].value, html); +}); + +test('empty flavors degrade to a plain-only payload and never promote html', () => { + const { api } = pasteboardStub(); + const { ClipboardRichPayload } = loadClipboard({ api, calls: {} }); + assert.deepEqual(ClipboardRichPayload.build(MARKDOWN, ''), [ + { mimeType: api.MIMETYPE_TEXT_PLAIN, value: MARKDOWN }, + ]); + // A missing markdown source still leaves the guaranteed plain flavor in + // charge so plain receivers never read an html-only clipboard. + assert.deepEqual(ClipboardRichPayload.build('', HTML), [ + { mimeType: api.MIMETYPE_TEXT_PLAIN, value: '' }, + { mimeType: api.MIMETYPE_TEXT_HTML, value: HTML }, + ]); + assert.deepEqual(ClipboardRichPayload.build('', ''), [ + { mimeType: api.MIMETYPE_TEXT_PLAIN, value: '' }, + ]); +}); + +test('copyRich writes the plain record first and adds the html record before setting data', async () => { + const stub = pasteboardStub(); + const { ClipboardService, ClipboardRichPayload } = loadClipboard(stub); + await new ClipboardService().copyRich(MARKDOWN, HTML); + assert.deepEqual(stub.calls.order, ['createData', 'createRecord', 'addRecord', 'setData']); + assert.deepEqual(stub.calls.createData, [ + { mimeType: ClipboardRichPayload.MIMETYPE_TEXT_PLAIN, value: MARKDOWN }, + ]); + assert.deepEqual(stub.calls.createRecord, [ + { mimeType: ClipboardRichPayload.MIMETYPE_TEXT_HTML, value: HTML }, + ]); + assert.equal(stub.calls.setData.length, 1); + assert.deepEqual(stub.calls.setData[0].records, stub.calls.addRecord); +}); + +test('copyRich with no html flavor performs the plain-only write', async () => { + const stub = pasteboardStub(); + const { ClipboardService } = loadClipboard(stub); + await new ClipboardService().copyRich(MARKDOWN, ''); + assert.deepEqual(stub.calls.order, ['createData', 'setData']); + assert.deepEqual(stub.calls.createData, [{ mimeType: 'text/plain', value: MARKDOWN }]); + assert.deepEqual(stub.calls.createRecord, []); + assert.deepEqual(stub.calls.addRecord, []); +}); + +test('writeText remains a single plain record write', async () => { + const stub = pasteboardStub(); + const { ClipboardService } = loadClipboard(stub); + await new ClipboardService().writeText(MARKDOWN); + assert.deepEqual(stub.calls.order, ['createData', 'setData']); + assert.deepEqual(stub.calls.createData, [{ mimeType: 'text/plain', value: MARKDOWN }]); + assert.deepEqual(stub.calls.addRecord, []); +}); + +test('copyRich surfaces pasteboard failures as clipboard errors', async () => { + const rpcError = pasteboardStub({ setDataError: new Error('denied') }); + await assert.rejects(new (loadClipboard(rpcError).ClipboardService)().copyRich(MARKDOWN, HTML), + error => error instanceof Error && error.message === 'denied'); + const businessError = pasteboardStub({ setDataError: { code: 401, message: 'not permitted' } }); + await assert.rejects(new (loadClipboard(businessError).ClipboardService)().copyRich(MARKDOWN, HTML), + error => error instanceof Error && /^clipboard rich write failed: /.test(error.message)); +}); + +test('copyRich wraps record construction failures inside the guarded block', async () => { + const stub = pasteboardStub({ createDataError: { code: 12900001, message: 'system error' } }); + await assert.rejects(new (loadClipboard(stub).ClipboardService)().copyRich(MARKDOWN, HTML), + error => error instanceof Error && /^clipboard rich write failed: /.test(error.message)); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs b/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs new file mode 100644 index 0000000000..c7322ecfe2 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs @@ -0,0 +1,171 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const root = path.resolve(__dirname, '../../entry/src/main'); +const english = JSON.parse(fs.readFileSync(path.join(root, 'resources/en_US/element/string.json'))).string; +const chinese = JSON.parse(fs.readFileSync(path.join(root, 'resources/zh_CN/element/string.json'))).string; +const realCatalog = { string: english.concat(chinese.map(entry => ({ ...entry, name: `${entry.name}.zh` }))) }; + +// Run the production ArkTS classes with only platform resource/decorator mocks. +function load(catalog = realCatalog) { + const cache = new Map(); + let language = 'zh-CN'; + const resourceManager = { + getRawFileContentSync(filename) { + assert.equal(filename, 'string.json'); + return Buffer.from(JSON.stringify(catalog)); + }, + getStringByNameSync(key) { + return (language === 'zh-CN' ? chinese : english).find(entry => entry.name === key).value; + } + }; + function module(name) { + if (cache.has(name)) return cache.get(name); + const source = fs.readFileSync(path.join(root, 'ets/i18n', `${name}.ets`), 'utf8'); + const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, experimentalDecorators: true } + }).outputText; + const exports = {}; + const requireMock = specifier => { + if (specifier === '@kit.ArkTS') return { util: { TextDecoder: { create: () => ({ decodeWithStream: bytes => Buffer.from(bytes).toString('utf8') }) } } }; + if (specifier === '@kit.LocalizationKit') return { intl: { DateTimeFormat: Intl.DateTimeFormat } }; + return module(specifier.replace('./', '')); + }; + new Function('require', 'exports', 'getContext', 'ObservedV2', 'Trace', compiled)( + requireMock, exports, () => ({ resourceManager }), value => value, () => {} + ); + cache.set(name, exports); + return exports; + } + return { + create: locale => new (module('HostTextRetranslator').HostTextRetranslator)(locale), + remote: module('RemoteI18n').RemoteI18n, + setResourceLanguage: locale => { language = locale; } + }; +} + +for (const locale of ['zh-CN', 'en-US']) { + test(`${locale}: exact and templated messages always converge to the target`, () => { + const r = load().create(locale); + for (const [en, zh] of [ + ['Back', '返回'], + ['Downloaded a.txt · 3 KB', '下载完成 a.txt · 3 KB'], + ['Switching to Desk-1…', '正在切换到 Desk-1…'], + ['3 more sessions', '还有 3 个会话'], + ['Show 3 more sessions', '再显示 3 条会话'] + ]) { + const expected = locale === 'zh-CN' ? zh : en; + for (const input of [en, zh]) { + assert.equal(r.retranslate(input), expected); + assert.equal(r.retranslate(r.retranslate(input)), expected); + } + } + }); + + test(`${locale}: every real catalog template translates with parameters intact`, () => { + const r = load().create(locale); + const source = locale === 'zh-CN' ? english : chinese; + const target = new Map((locale === 'zh-CN' ? chinese : english).map(e => [e.name, e.value])); + const fill = template => template.replace(/%[12]\$s/g, slot => slot === '%1$s' ? 'VALUE' : 'SIZE'); + for (const entry of source.filter(e => e.value.includes('%1$s'))) { + const expected = fill(target.get(entry.name)); + assert.equal(r.retranslate(fill(entry.value)), expected, entry.name); + assert.equal(r.retranslate(expected), expected, entry.name); + } + }); + + test(`${locale}: restricted status translation never falls back to the full catalog`, () => { + const r = load().create(locale); + for (const name of ['Back', '3 sessions', 'Show 3 more sessions', 'my workspace']) { + assert.equal(r.retranslateKnown(name, ['status.notConnected']), name); + } + for (const input of ['未连接', 'Not connected']) { + assert.equal(r.retranslateKnown(input, ['status.notConnected']), locale === 'zh-CN' ? '未连接' : 'Not connected'); + } + }); + + test(`${locale}: workspace identity protects even names identical to placeholders`, () => { + const { remote } = load(); + remote.setLanguage(locale); + for (const name of ['Back', '3 sessions', 'Not connected', '未连接']) { + assert.equal(remote.retranslateWorkspaceName(name, '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/remote/project', ''), name); + assert.equal(remote.retranslateWorkspaceName(name, '', 'workspace-id'), name); + } + assert.equal(remote.retranslateWorkspaceName('Back', '', ''), 'Back'); + assert.equal(remote.retranslateWorkspaceName('未连接', '', ''), locale === 'zh-CN' ? '未连接' : 'Not connected'); + }); +} + +test('literal parameters survive initial formatting and language refresh', () => { + const { remote, setResourceLanguage } = load(); + remote.setLanguage('en-US'); + setResourceLanguage('en-US'); + for (const filename of ['budget$$.txt', 'x$&y.txt', "x$`y.txt", "x$'y.txt", 'a%2$s.txt', 'a%1$s.txt']) { + const initial = remote.f2('status.downloadDone', filename, '3 KB'); + assert.equal(initial, `Downloaded ${filename} · 3 KB`); + remote.setLanguage('zh-CN'); + assert.equal(remote.retranslate(initial), `下载完成 ${filename} · 3 KB`); + remote.setLanguage('en-US'); + } +}); + +test('messages produced after locale update remain stable when asynchronous save completes', async () => { + const { remote, setResourceLanguage } = load(); + remote.setLanguage('en-US'); + remote.setLanguage('zh-CN'); + setResourceLanguage('zh-CN'); + const completedDuringSave = remote.f2('status.downloadDone', 'a.txt', '3 KB'); + await Promise.resolve(); + assert.equal(remote.retranslate(completedDuringSave), completedDuringSave); +}); + +test('unknown and ambiguous messages are retained', () => { + const catalog = { string: [ + { name: 'first', value: 'Duplicate' }, { name: 'second', value: 'Duplicate' }, + { name: 'first.zh', value: '重复' }, { name: 'second.zh', value: '重复' }, + { name: 'one', value: '%1$s items' }, { name: 'two', value: '%1$s items' }, + { name: 'one.zh', value: '%1$s 项' }, { name: 'two.zh', value: '%1$s 条' } + ] }; + for (const locale of ['en-US', 'zh-CN']) { + const r = load(catalog).create(locale); + for (const message of ['', 'unknown', 'Duplicate', '重复', '3 items', '3 项', '3 条']) { + assert.equal(r.retranslate(message), message); + } + } +}); + +test('equally specific overlapping templates are not guessed', () => { + const r = load({ string: [ + { name: 'first', value: 'A%1$s' }, { name: 'first.zh', value: '甲%1$s' }, + { name: 'second', value: '%1$sB' }, { name: 'second.zh', value: '%1$s乙' } + ] }).create('zh-CN'); + assert.equal(r.retranslate('AB'), 'AB'); +}); + +test('shared runtime refresh preserves workspace identity and refreshes download status', () => { + const source = fs.readFileSync(path.join(root, 'ets/pages/runtime/AppRootRuntimeComposition.ets'), 'utf8'); + const body = source.split(' refreshLocalizedRuntimeCopy(): void {')[1].split('\n }')[0]; + const { remote } = load(); + remote.setLanguage('zh-CN'); + const state = { + statusText: 'Downloaded budget$$.txt · 3 KB', + sessionErrorText: '', + workspaceName: 'Not connected', workspacePath: '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/remote/project', workspaceId: 'id', + fileDownloadStatus: 'Downloaded budget$$.txt · 3 KB', + setStatusText(value) { this.statusText = value; }, + setError(value) { this.sessionErrorText = value; }, + setWorkspace(name) { this.workspaceName = name; }, + setDownloadStatus(_downloading, _downloaded, value) { this.fileDownloadStatus = value; } + }; + const context = { remotePageState: state, filePreviewState: { errorText: '' } }; + const refresh = new Function('RemoteI18n', 'ConnectionStatusPresenter', body); + for (let index = 0; index < 2; index++) { + refresh.call(context, remote, { labelKey: () => 'status.notConnected' }); + assert.equal(state.statusText, '下载完成 budget$$.txt · 3 KB'); + assert.equal(state.fileDownloadStatus, state.statusText); + assert.equal(state.workspaceName, 'Not connected'); + } +}); 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..c5b9dbf430 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'); @@ -33,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) { @@ -68,6 +80,43 @@ 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; +} + +// 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 @@ -80,19 +129,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', () => { @@ -104,6 +158,180 @@ 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 + // 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. + // + // 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 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 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 + // 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', '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 + // 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 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`); + 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'); +}); + +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 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 @@ -116,12 +344,16 @@ 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.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. @@ -132,9 +364,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'); }); @@ -148,25 +384,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]]) { @@ -218,7 +455,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'); @@ -334,9 +572,102 @@ 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\], \[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', () => { + // 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('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 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 [ + ['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.START, SafeAreaEdge.END, 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 — + // `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', () => { @@ -353,3 +684,122 @@ 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}`); + } +}); + +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'); +}); + +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'); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/markdown-html-serializer.test.cjs b/src/apps/mobile/harmonyos/tools/tests/markdown-html-serializer.test.cjs new file mode 100644 index 0000000000..ef3c337b1b --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/markdown-html-serializer.test.cjs @@ -0,0 +1,254 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// The serializer is pure string work over the parsed tree, so the host runner +// exercises the real .ets source: the parser is transpiled too and injected as +// the serializer's './MarkdownParser' dependency. +const SERVICES = path.join(__dirname, '../../entry/src/main/ets/services'); + +function load(name, dependencies = {}) { + const source = fs.readFileSync(path.join(SERVICES, `${name}.ets`), 'utf8'); + const compiled = ts.transpileModule(source, { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS + } }).outputText; + const moduleObject = { exports: {} }; + new Function('require', 'exports', 'module', compiled)( + (specifier) => { + const resolved = dependencies[specifier]; + if (!resolved) { + throw new Error(`unexpected import ${specifier}`); + } + return resolved; + }, + moduleObject.exports, + moduleObject + ); + return moduleObject.exports; +} + +const parserModule = load('MarkdownParser'); +const serializerModule = load('MarkdownHtmlSerializer', { './MarkdownParser': parserModule }); +const { MarkdownParser } = parserModule; +const { MarkdownHtmlSerializer } = serializerModule; +const html = (text) => MarkdownHtmlSerializer.toHtml(text); + +test('empty and whitespace-only input produce no payload', () => { + assert.equal(html(''), ''); + assert.equal(html(' '), ''); + assert.equal(html('\n\n\t\n'), ''); + assert.equal(MarkdownHtmlSerializer.blocksToHtml([]), ''); +}); + +test('every heading level maps to h1..h6 and clamps out-of-range levels', () => { + const markers = ['#', '##', '###', '####', '#####', '######']; + markers.forEach((marker, index) => { + const level = index + 1; + assert.equal(html(`${marker} Title`), `Title`); + }); + assert.equal(html('Setext\n==='), '

    Setext

    '); + assert.equal(html('Setext\n---'), '

    Setext

    '); + assert.equal( + MarkdownHtmlSerializer.blocksToHtml([block({ type: 'heading', level: 9, text: 'x' })]), + '
    x
    ' + ); + assert.equal( + MarkdownHtmlSerializer.blocksToHtml([block({ type: 'heading', level: 0, text: 'x' })]), + '

    x

    ' + ); +}); + +test('paragraphs render the parser inline tree', () => { + assert.equal(html('plain paragraph'), '

    plain paragraph

    '); + assert.equal( + html('plain **bold** and *em* and `code` and ~~gone~~'), + '

    plain bold and em and code and gone

    ' + ); + assert.equal( + html('**bold** then `code` in one paragraph'), + '

    bold then code in one paragraph

    ' + ); +}); + +test('inline markers nested inside a styled run stay literal text', () => { + // The parser keeps a flat inline list: strong/emphasis/delete carry their raw + // inner text, so the serializer escapes it instead of re-parsing markup. + assert.equal(html('**bold `code`**'), '

    bold `code`

    '); +}); + +test('text nodes and attribute values are HTML escaped', () => { + assert.equal(html('a bold tag'), '

    a <b>bold</b> tag

    '); + assert.equal(html('a & b "c"'), '

    a & b "c"

    '); + assert.equal( + html('[a & b](https://example.com/?x=1&y=2)'), + '

    a & b

    ' + ); + assert.equal( + html('[say "hi"](https://example.com)'), + '

    say "hi"

    ' + ); +}); + +test('only allowlisted URL schemes render as anchors', () => { + assert.equal(html('[docs](https://example.com/a)'), '

    docs

    '); + assert.equal(html('[l](computer://node/path)'), '

    l

    '); + assert.equal(html('[f](file:///tmp/a.txt)'), '

    f

    '); + assert.equal( + html('[u](HTTPS://EXAMPLE.COM/A)'), + '

    u

    ' + ); + assert.equal( + html(''), + '

    https://example.com/x

    ' + ); +}); + +test('unsafe URL schemes degrade to plain text instead of an anchor', () => { + // The parser stops the URL at the first ')', so the leftover paren survives as + // literal text: the label renders, the scheme never reaches an attribute. + const injected = html('[bad](javascript:alert(1))'); + assert.equal(injected, '

    bad)

    '); + assert.doesNotMatch(injected, /javascript/i); + assert.equal(html('[drop](ftp://example.com/a)'), '

    drop

    '); + assert.equal(html('[data](data:text/html,x)'), '

    data

    '); +}); + +test('unordered, ordered and nested list markers pick their list element', () => { + assert.equal(html('- a\n- b'), '
    • a
    • b
    '); + assert.equal(html('* a\n+ b'), '
    • a
    • b
    '); + assert.equal(html('1. a\n2. b'), '
    1. a
    2. b
    '); + assert.equal(html('1) a'), '
    1. a
    '); + assert.equal(html('- **bold** item'), '
    • bold item
    '); + assert.equal(html('- a\n - b\n- c'), '
    • a
      • b
    • c
    '); +}); + +test('task list items carry a disabled checkbox with their checked state', () => { + assert.equal( + html('- [x] done\n- [ ] todo'), + '
    • done
    • ' + + '
    • todo
    ' + ); + assert.equal( + html('- [X] upper\n- plain'), + '
    • upper
    • plain
    ' + ); +}); + +test('quotes nest one blockquote per parser depth', () => { + assert.equal(html('> quoted'), '
    quoted
    '); + assert.equal(html('> > deep'), '
    deep
    '); + assert.equal(html('> **bold** quote'), '
    bold quote
    '); +}); + +test('dividers render as hr', () => { + assert.equal(html('a\n\n---\n\nb'), '

    a


    b

    '); + assert.equal(html('***'), '
    '); + assert.equal(html('___'), '
    '); +}); + +test('code blocks keep their language class and escape the body', () => { + assert.equal( + html('```ts\nconst a = 1 < 2;\n```'), + '
    const a = 1 < 2;
    ' + ); + assert.equal(html('```\nplain\n```'), '
    plain
    '); + assert.equal( + html('```a"b\nx\n```'), + '
    x
    ' + ); +}); + +test('frontmatter renders as an escaped pre block', () => { + assert.equal( + html('---\ntitle: a & b\n---\n\nbody'), + '
    title: a & b

    body

    ' + ); +}); + +test('tables render thead/tbody with per-column alignment styles', () => { + const document = [ + '| left | center | right |', + '| --- | :---: | ---: |', + '| a | b | c |' + ].join('\n'); + assert.equal( + html(document), + '' + + '' + + '' + + '
    leftcenterright
    abc
    ' + ); + const inlineCells = ['| n | m |', '| --- | --- |', '| **x** | `y` |'].join('\n'); + assert.equal( + html(inlineCells), + '' + + '
    nm
    xy
    ' + ); +}); + +test('a full document serializes every block type in order', () => { + const document = [ + '---', + 'title: Doc', + '---', + '', + '# Heading', + '', + 'Some **bold** text with a [link](https://example.com) and `code`.', + '', + '> quoted line', + '', + '- [x] done', + '- plain item', + '', + '1. first', + '2. second', + '', + '```ts', + 'const a = 1 < 2;', + '```', + '', + '| left | right |', + '| --- | ---: |', + '| 1 | 2 |', + '', + '---' + ].join('\n'); + assert.equal( + html(document), + '
    title: Doc
    ' + + '

    Heading

    ' + + '

    Some bold text with a ' + + 'link and code.

    ' + + '
    quoted line
    ' + + '
    • done
    • plain item
    ' + + '
    1. first
    2. second
    ' + + '
    const a = 1 < 2;
    ' + + '' + + '
    leftright
    12
    ' + + '
    ' + ); +}); + +test('blocksToHtml serializes a parsed tree without reparsing', () => { + const blocks = MarkdownParser.parse('# Title\n\nbody'); + assert.equal(MarkdownHtmlSerializer.blocksToHtml(blocks), '

    Title

    body

    '); + assert.deepEqual(blocks.map((entry) => entry.type), ['heading', 'paragraph']); +}); + +/** Builds a parser-shaped block so a single field can be probed in isolation. */ +function block(fields) { + return Object.assign({ + id: 'md-0', + renderKey: 'md-0', + type: 'paragraph', + level: 0, + language: '', + text: '', + items: [], + inlines: [], + tableRows: [] + }, fields); +} diff --git a/src/apps/mobile/harmonyos/tools/tests/selection-capability.test.cjs b/src/apps/mobile/harmonyos/tools/tests/selection-capability.test.cjs new file mode 100644 index 0000000000..30d890e4e2 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/selection-capability.test.cjs @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// The gate is pure policy, so the host runner exercises the real .ets source +// without any ArkUI stubbing. +const POLICY = path.join(__dirname, '../../entry/src/main/ets/pages/policy/SelectionCapabilityPolicy.ets'); +const MARKDOWN_CONTENT = path.join(__dirname, '../../entry/src/main/ets/pages/components/MarkdownContent.ets'); + +function loadPolicy() { + const source = fs.readFileSync(POLICY, 'utf8'); + const compiled = ts.transpileModule(source, { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS + } }).outputText; + const exported = {}; + new Function('exports', compiled)(exported); + return exported.SelectionCapabilityPolicy; +} + +const SelectionCapabilityPolicy = loadPolicy(); +const supports = (version) => SelectionCapabilityPolicy.supportsCrossBlockSelection(version); + +test('API 26 is the first level that selects across blocks', () => { + assert.equal(SelectionCapabilityPolicy.CROSS_BLOCK_SELECTION_API_LEVEL, 26); + assert.equal(supports(26), true); +}); + +test('levels around the boundary fall on the side of their own API', () => { + assert.equal(supports(25), false); + assert.equal(supports(24), false); + assert.equal(supports(21), false); + assert.equal(supports(26.9), true); + assert.equal(supports(27), true); + assert.equal(supports(100), true); +}); + +test('an unmeasurable level never opts into the API 26 path', () => { + const junk = [undefined, null, NaN, Infinity, -Infinity, 0, -1, -26, '', '26', {}, [], true, () => 26]; + for (const value of junk) { + assert.equal(supports(value), false, `${String(value)} must not enable cross-block selection`); + } +}); + +test('a failed platform read reports no API level at all', () => { + // The service is the only place that reads the device, and its failure value + // has to be one the gate rejects, or an unmeasurable device would be trusted. + const source = fs.readFileSync( + path.join(__dirname, '../../entry/src/main/ets/services/DeviceApiLevelService.ets'), + 'utf8' + ); + const failureValue = /catch\s*\(_err\)\s*\{\s*return\s+(-?\d+n?|NaN)/.exec(source); + assert.ok(failureValue, 'DeviceApiLevelService must report a value on a failed read'); + assert.equal(supports(Number(failureValue[1].replace('n', ''))), false); +}); + +test('the capability gate stays free of platform imports', () => { + // Keeping device reads out of the policy is what makes the boundary testable + // here and what keeps components off platform kits through a policy file. + const source = fs.readFileSync(POLICY, 'utf8'); + assert.doesNotMatch(source, /^\s*import\b/m); +}); + +test('markdown renders one shared block column behind both selection paths', () => { + // The API<26 fallback has to be the same subtree, not a second copy of it, so + // the two paths must call one builder and that builder must own the blocks. + const source = fs.readFileSync(MARKDOWN_CONTENT, 'utf8'); + const callSites = (source.match(/this\.MarkdownBlockColumn\(\)/g) || []).length; + assert.equal(callSites, 2); + assert.equal((source.match(/@Builder\s+MarkdownBlockColumn\(\)/g) || []).length, 1); + assert.match( + source, + /SelectionContainer\(\)\s*\{\s*this\.MarkdownBlockColumn\(\)\s*\}[\s\S]*?\.copyOption\(CopyOptions\.LocalDevice\)[\s\S]*?\.textJoinStyle\(SelectionContainerTextJoinStyle\.NEWLINE\)/ + ); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/user-message-menu.test.cjs b/src/apps/mobile/harmonyos/tools/tests/user-message-menu.test.cjs new file mode 100644 index 0000000000..7c28916101 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/user-message-menu.test.cjs @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// The truncated bubble's long-press menu is composed from a policy list so the +// rollback entry can join it as data. Exercise the list itself; the anchored +// menu's rendering and dismissal stay a device-level check. +function loadEts(file) { + const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, + } }).outputText; + const exported = {}; + new Function('require', 'exports', compiled)(() => ({}), exported); + return exported; +} + +const etsRoot = path.join(__dirname, '../../entry/src/main/ets'); +const resourcesRoot = path.join(__dirname, '../../entry/src/main/resources'); +const { UserMessageMenuAction, UserMessageMenuPolicy } = loadEts( + path.join(etsRoot, 'pages/policy/UserMessageMenuPolicy.ets') +); + +function catalog(locale) { + const source = fs.readFileSync(path.join(resourcesRoot, locale, 'element/string.json'), 'utf8'); + const entries = JSON.parse(source).string; + const values = new Map(); + for (const entry of entries) { + values.set(entry.name, entry.value); + } + return values; +} + +test('the truncated bubble menu carries copy-full-text and nothing else yet', () => { + const items = UserMessageMenuPolicy.items(); + assert.equal(items.length, 1); + assert.equal(items[0].action, UserMessageMenuAction.CopyFullText); + assert.equal(items[0].labelKey, 'chat.copyFullText'); +}); + +test('every menu item names a distinct action, so a second entry cannot collide', () => { + const items = UserMessageMenuPolicy.items(); + const actions = items.map((item) => item.action); + assert.equal(new Set(actions).size, actions.length); + assert.equal(UserMessageMenuPolicy.items()[0].action, UserMessageMenuAction.CopyFullText); +}); + +test('each call hands back its own list', () => { + const first = UserMessageMenuPolicy.items(); + first.push({ action: 'scratch', labelKey: 'scratch' }); + assert.equal(UserMessageMenuPolicy.items().length, 1); +}); + +test('this package adds its copy to every locale catalog', () => { + const expected = { + chat_fullText: { 'en_US': 'Full text', 'zh_CN': '全文' }, + chat_copyFullText: { 'en_US': 'Copy full text', 'zh_CN': '复制全文' }, + }; + for (const [name, values] of Object.entries(expected)) { + for (const [locale, value] of Object.entries(values)) { + assert.equal(catalog(locale).get(name), value, `${name} in ${locale}`); + } + } +}); + +test('the menu labels resolve against the resource names the app asks for', () => { + for (const locale of ['en_US', 'zh_CN']) { + const values = catalog(locale); + for (const item of UserMessageMenuPolicy.items()) { + const resourceName = item.labelKey.replace(/\./g, '_'); + assert.equal(values.has(resourceName), true, `${resourceName} in ${locale}`); + assert.ok(values.get(resourceName).length > 0, `${resourceName} in ${locale} is empty`); + } + } +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/user-message-truncation.test.cjs b/src/apps/mobile/harmonyos/tools/tests/user-message-truncation.test.cjs new file mode 100644 index 0000000000..7da095fcfa --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/user-message-truncation.test.cjs @@ -0,0 +1,131 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +// The truncation rule is display policy: exercise the real module the bubble +// calls, so the eight-line / 360-character boundary is host-verifiable. The +// bubble's own layout stays a device-level check. +function loadEts(file) { + const compiled = ts.transpileModule(fs.readFileSync(file, 'utf8'), { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, + } }).outputText; + const exported = {}; + new Function('require', 'exports', compiled)(() => ({}), exported); + return exported; +} + +const policyDir = path.join(__dirname, '../../entry/src/main/ets/pages/policy'); +const { UserMessageTruncationPolicy } = loadEts( + path.join(policyDir, 'UserMessageTruncationPolicy.ets') +); + +function lines(count, separator = '\n') { + return Array.from({ length: count }, (_value, index) => `line${index}`).join(separator); +} + +test('frozen thresholds are eight lines, 360 characters and the three-dot ellipsis', () => { + assert.equal(UserMessageTruncationPolicy.MAX_PREVIEW_LINES, 8); + assert.equal(UserMessageTruncationPolicy.MAX_PREVIEW_CHARS, 360); + assert.equal(UserMessageTruncationPolicy.ELLIPSIS, '...'); +}); + +test('short text is left completely untouched', () => { + const text = 'Ship the mobile truncation rule'; + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), false); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), text); +}); + +test('empty and whitespace-only messages are never truncated', () => { + for (const text of ['', ' ', '\n', ' \n\t ']) { + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), false); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), text); + } +}); + +test('a message of exactly eight lines keeps every line', () => { + const text = lines(8); + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), false); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), text); +}); + +test('a nine-line message cuts at the eighth line break and drops the ninth line', () => { + const preview = UserMessageTruncationPolicy.truncatedPreview(lines(9)); + assert.equal(preview, `${lines(8)}...`); + assert.equal(preview.includes('line8'), false); + assert.equal(preview.split('\n').length, 8); +}); + +test('a message of exactly 360 characters keeps all of them', () => { + const text = 'a'.repeat(360); + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), false); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), text); +}); + +test('a message of 361 characters cuts at 360 and appends the ellipsis', () => { + const text = `${'a'.repeat(360)}b`; + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), true); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), `${'a'.repeat(360)}...`); +}); + +test('the character limit binds before the line limit on a single long line', () => { + const text = 'x'.repeat(900); + const preview = UserMessageTruncationPolicy.truncatedPreview(text); + assert.equal(preview.length, 363); + assert.equal(preview, `${'x'.repeat(360)}...`); +}); + +test('the line limit binds before the character limit on many short lines', () => { + const text = lines(40); + const preview = UserMessageTruncationPolicy.truncatedPreview(text); + assert.equal(preview, `${lines(8)}...`); + assert.ok(text.length < UserMessageTruncationPolicy.MAX_PREVIEW_CHARS); +}); + +test('a line break before eight lines still cuts when 360 characters come first', () => { + // Two lines whose second one starts inside the character limit: the cut lands + // just past the line break, and the ellipsis still joins the first line. + const text = `${'a'.repeat(359)}\n${'b'.repeat(20)}`; + const preview = UserMessageTruncationPolicy.truncatedPreview(text); + assert.equal(preview, `${'a'.repeat(359)}...`); +}); + +test('trailing whitespace is dropped so the ellipsis never follows a gap', () => { + const text = `${'a'.repeat(359)} ${'b'.repeat(4)}`; + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), true); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), `${'a'.repeat(359)}...`); +}); + +test('CRLF collapses to LF before the line count is taken', () => { + const text = lines(9, '\r\n'); + assert.equal(UserMessageTruncationPolicy.normalize(text), lines(9, '\n')); + const preview = UserMessageTruncationPolicy.truncatedPreview(text); + assert.equal(preview, `${lines(8)}...`); + assert.equal(preview.includes('\r'), false); +}); + +test('a CRLF message of exactly eight lines is not truncated', () => { + const text = lines(8, '\r\n'); + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), false); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), lines(8)); +}); + +test('a cut that hides only empty trailing lines does not truncate', () => { + const text = `${lines(8)}\n\n\n`; + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), false); + assert.equal(UserMessageTruncationPolicy.truncatedPreview(text), text); +}); + +test('CRLF is not double-counted towards the character limit', () => { + const text = `${'a'.repeat(179)}\r\n${'b'.repeat(180)}`; + assert.equal(text.length, 361); + assert.ok(text.length > UserMessageTruncationPolicy.MAX_PREVIEW_CHARS); + // 360 normalized characters across two lines, so the message is not long by + // either limit even though the raw string crosses the character threshold. + assert.equal(UserMessageTruncationPolicy.shouldTruncate(text), false); + assert.equal( + UserMessageTruncationPolicy.truncatedPreview(text), + `${'a'.repeat(179)}\n${'b'.repeat(180)}` + ); +}); diff --git a/src/apps/mobile/harmonyos/tools/tests/window-insets-lifecycle.test.cjs b/src/apps/mobile/harmonyos/tools/tests/window-insets-lifecycle.test.cjs new file mode 100644 index 0000000000..1b5005b987 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/window-insets-lifecycle.test.cjs @@ -0,0 +1,105 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const ts = require('typescript'); + +const source = fs.readFileSync(path.join(__dirname, + '../../entry/src/main/ets/services/WindowSystemBarService.ets'), 'utf8'); +const compiled = ts.transpileModule(source, { compilerOptions: { + target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, experimentalDecorators: true +} }).outputText; + +function harness() { + const requests = []; + const window = { + AvoidAreaType: { TYPE_SYSTEM: 0, TYPE_NAVIGATION_INDICATOR: 1, TYPE_CUTOUT: 2, TYPE_KEYBOARD: 3 }, + getLastWindow() { + return new Promise((resolve, reject) => requests.push({ resolve, reject })); + } + }; + const exports = {}; + new Function('require', 'exports', 'ObservedV2', 'Trace', compiled)( + name => name === '@kit.ArkUI' ? { window } : { RemoteLogger: { info() {}, error() {} } }, + exports, value => value, () => {} + ); + return { requests, Binding: exports.WindowInsetsBinding }; +} + +function appWindow(right) { + let listener; + let detached = 0; + return { + getWindowAvoidArea(type) { + return { + topRect: { height: 0 }, bottomRect: { height: type === 1 ? 28 : 0 }, + leftRect: { width: 0 }, rightRect: { width: type === 2 ? right : 0 } + }; + }, + on(_event, callback) { listener = callback; }, + off(_event, callback) { + assert.equal(callback, listener); + detached++; + }, + change(value) { right = value; listener({}); }, + get detached() { return detached; } + }; +} + +const ui = { px2vp: value => value }; +const settle = () => new Promise(resolve => setImmediate(resolve)); + +for (const outcome of ['success', 'failure']) { + test(`late ${outcome} from a closed generation cannot replace live insets`, async () => { + const { Binding, requests } = harness(); + const old = new Binding(); + old.bind(ui, {}); + old.unbind(); + const current = new Binding(); + current.bind(ui, {}); + const liveWindow = appWindow(64); + requests[1].resolve(liveWindow); + await settle(); + assert.equal(current.right, 64); + + const staleWindow = appWindow(4); + if (outcome === 'success') requests[0].resolve(staleWindow); + else requests[0].reject(new Error('Previous window was destroyed')); + await settle(); + assert.equal(current.right, 64, 'obsolete completion must not overwrite the live broadcast'); + assert.equal(current.bottom, 28); + if (outcome === 'success') assert.equal(staleWindow.detached, 1); + + const sibling = new Binding(); + sibling.bind(ui, {}); + assert.equal(sibling.right, 64, 'new listeners must receive an unpolluted cached value'); + assert.equal(requests.length, 2, 'live bindings still share one window subscription'); + liveWindow.change(32); + assert.equal(current.right, 32); + assert.equal(sibling.right, 32); + current.unbind(); + assert.equal(liveWindow.detached, 0); + sibling.unbind(); + assert.equal(liveWindow.detached, 1); + }); +} + +test('completion after the last unbind cannot seed the next generation cache', async () => { + const { Binding, requests } = harness(); + const old = new Binding(); + old.bind(ui, {}); + old.unbind(); + const staleWindow = appWindow(99); + requests[0].resolve(staleWindow); + await settle(); + assert.equal(staleWindow.detached, 1); + const next = new Binding(); + next.bind(ui, {}); + assert.equal(next.right, 0, 'a closed generation must not publish into the cache'); + const liveWindow = appWindow(64); + requests[1].resolve(liveWindow); + await settle(); + assert.equal(next.right, 64); + next.unbind(); + assert.equal(liveWindow.detached, 1); +});