From 9708b91f85c94318b780b4f9a1f8382fa64224e0 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 03:36:33 +0800 Subject: [PATCH 01/13] feat(mobile): add rich clipboard payload with markdown and html flavors --- .../main/ets/services/ClipboardService.ets | 59 +++++++ .../tests/clipboard-rich-payload.test.cjs | 152 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 src/apps/mobile/harmonyos/tools/tests/clipboard-rich-payload.test.cjs 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/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)); +}); From 7898d313ed0f9b18499c86dbacc54ddf4d1f2d2f Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 03:39:12 +0800 Subject: [PATCH 02/13] feat(mobile): add MarkdownHtmlSerializer for rich clipboard export --- .../ets/services/MarkdownHtmlSerializer.ets | 262 ++++++++++++++++++ .../tests/markdown-html-serializer.test.cjs | 254 +++++++++++++++++ 2 files changed, 516 insertions(+) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/MarkdownHtmlSerializer.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/markdown-html-serializer.test.cjs 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/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); +} From 09360baaae2f4b03ffdc22929543bedb5950802b Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 03:44:06 +0800 Subject: [PATCH 03/13] chore(mobile): bump targetSdkVersion to API 26 for cross-block selection --- src/apps/mobile/harmonyos/build-profile.json5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From 469feae79c8f07d106dac13ede10976f94f2c0fc Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 03:56:58 +0800 Subject: [PATCH 04/13] feat(mobile): cross-block text selection for assistant markdown with API-level fallback Assistant answers render as one Text per markdown block, so a long press could only ever select the block under the finger. Wrap the assistant block column in SelectionContainer on API 26, where one press and its handles span paragraphs, list items, tables and code blocks; below 26 the same column renders bare and every Text keeps its own copyOption, so a single block is still selectable and copyable. The API boundary is a pure policy over the device's reported level, read once behind DeviceApiLevelService: an unmeasurable device degrades to the per-Text path instead of calling an API 26 component. Selection colors stay with the platform, which is what the approved UI draft records for the missing caret/highlight tokens; the handles take the app's information accent. Streamed answers are unchanged: their reveal still rewrites the rendered prefix every frame. --- .../ets/pages/components/MarkdownContent.ets | 63 +++++++++++++++ .../policy/SelectionCapabilityPolicy.ets | 28 +++++++ .../ets/services/DeviceApiLevelService.ets | 19 +++++ .../tools/tests/selection-capability.test.cjs | 76 +++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/SelectionCapabilityPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/services/DeviceApiLevelService.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/selection-capability.test.cjs 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..6fcb26369f 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 }) @@ -265,6 +323,11 @@ 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 non-selectable: + // a long press there would fight the copy button's own click. + .copyOption(CopyOptions.LocalDevice) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) 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/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/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\)/ + ); +}); From b2d89e59c84e39d5f67f6a1b352fef9e27a12c2d Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 04:01:03 +0800 Subject: [PATCH 05/13] feat(mobile): truncate long user messages with a full-text half-modal panel --- .../pages/components/AppRootPresentation.ets | 8 ++ .../pages/components/ChatMessageBubble.ets | 9 +- .../pages/components/ChatMessageChrome.ets | 74 +++++++++- .../ets/pages/components/ChatTimeline.ets | 13 ++ .../components/ConversationRouteSurface.ets | 8 ++ .../ets/pages/components/ConversationView.ets | 42 ++++++ .../components/ConversationViewContract.ets | 13 ++ .../pages/components/ConversationViewHost.ets | 8 ++ .../components/UserMessageFullTextSheet.ets | 69 +++++++++ .../pages/components/WideConversationHost.ets | 3 + .../pages/policy/SettingsPlacementPolicy.ets | 8 +- .../pages/policy/UserMessageMenuPolicy.ets | 32 +++++ .../policy/UserMessageTruncationPolicy.ets | 101 ++++++++++++++ .../main/resources/en_US/element/string.json | 8 ++ .../main/resources/zh_CN/element/string.json | 8 ++ .../tools/tests/user-message-menu.test.cjs | 76 ++++++++++ .../tests/user-message-truncation.test.cjs | 131 ++++++++++++++++++ 17 files changed, 607 insertions(+), 4 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/UserMessageFullTextSheet.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageMenuPolicy.ets create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/UserMessageTruncationPolicy.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/user-message-menu.test.cjs create mode 100644 src/apps/mobile/harmonyos/tools/tests/user-message-truncation.test.cjs 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/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 0ccf4a68f8..7ff3e8fc0f 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 @@ -33,12 +33,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 +76,7 @@ export struct ChatMessageBubble { @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @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) => {}; @@ -332,7 +337,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( 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..712b8653cc 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,14 @@ import { MobileDesignGeometry, MobileDesignTypography } from '../../generated/MobileDesignTokens'; import { RemoteI18n } from '../../i18n/RemoteI18n'; +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, INK, MUTED, CONTENT_ON_ACTION, STATUS_DANGER, SOFT, FILE_LINK } from './Theme'; @ComponentV2 export struct ChatTypingDots { @@ -72,6 +78,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 +92,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,6 +122,62 @@ 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. + */ + @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) + .width('100%') + .textAlign(TextAlign.Start) + .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; 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..125b8ae82b 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 @@ -57,6 +57,7 @@ struct ChatAssistantTimelineRow { @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @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 +99,7 @@ struct ChatAssistantTimelineRow { onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, onCopyMessage: this.onCopyMessage, + onOpenFullText: this.onOpenFullText, onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, onBuildPlan: this.onBuildPlan, @@ -163,6 +165,7 @@ export struct ChatTimeline { @Event onAnswerQuestion: (toolId: string, answers: ConversationUiQuestionAnswer) => void = (_toolId: string, _answers: ConversationUiQuestionAnswer) => {}; @Event onCopyMessage: (text: string) => void = (_text: string) => {}; + @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 +244,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 +281,9 @@ export struct ChatTimeline { onCopyMessage: (text: string) => { this.onCopyMessage(text); }, + onOpenFullText: (text: string) => { + this.onOpenFullText(text); + }, onRetryMessage: (text: string) => { this.onRetryMessage(text); }, @@ -317,6 +329,7 @@ export struct ChatTimeline { onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, onCopyMessage: this.onCopyMessage, + 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..0d376ab9ad 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 @@ -10,8 +10,11 @@ import { ChatComposerCapabilities } from '../state/ChatComposerCapabilities'; import { ChatSurface } from '../state/ChatSurface'; import { ConversationViewState } from '../state/ConversationViewState'; import { ConversationIntent, ConversationIntents, ConversationIntentType } 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; + } + }) } /** @@ -377,6 +402,9 @@ export struct ConversationView { onCopyMessage: (text: string) => { this.onCopyMessage(text); }, + onOpenFullText: (text: string) => { + this.openFullText(text); + }, onRetryMessage: (text: string) => { this.onRetryMessage(text); }, @@ -618,6 +646,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); } @@ -643,6 +672,19 @@ export struct ConversationView { } private onCopyMessage(text: string): void { this.dispatchValue(ConversationIntentType.CopyMessage, text); } 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/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/WideConversationHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets index e334010441..3fe8bea627 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; @@ -362,6 +364,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/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/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index 3e30be5306..5be9ed2fab 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,14 @@ "name": "chat_analyzeImageDefault", "value": "Please analyze this image." }, + { + "name": "chat_fullText", + "value": "Full text" + }, + { + "name": "chat_copyFullText", + "value": "Copy full 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..10b6fab42b 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,14 @@ "name": "chat_analyzeImageDefault", "value": "请分析这张图片。" }, + { + "name": "chat_fullText", + "value": "全文" + }, + { + "name": "chat_copyFullText", + "value": "复制全文" + }, { "name": "status_waitingConnection", "value": "等待连接" 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)}` + ); +}); From 2776a0d7d9f8ff05bcbe4d9f3eedc84c9e6d7e8f Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 04:16:31 +0800 Subject: [PATCH 06/13] feat(mobile): scoped copy menu for assistant messages with rich-text export The assistant bubble gains the frozen bottom action row: a SOFT copy capsule beside the retry pill, opening an anchored popover rather than a bottom sheet. Its three entries are the export scopes the pasteboard can carry, and the list stays data so each one is assertable without a device. MessageCopyScope extends the CopyMessage intent and defaults to plain, so the code-block copy button and the user message's full-text copy keep the single-flavor write they had; only rich changes the payload, writing the Markdown source as the plain flavor plus the serialized html. The plain entry is the only one that renders the source down, through the parser's existing inline pass. Both the menu and the drawn answer read one body: the bubble's finalMessageText gathering, not a second reading of items that could drift from the screen. The action row now hosts the retry pill too, under the exact condition the previous else-if used, so no failed-state behavior changes. 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 was available to reuse and device verification owns that gap. This also repairs the full-text callback seam the two preceding commits left in ConversationView, where CompileArkTS failed on this.openFullText. --- .../ets/pages/actions/ConversationIntent.ets | 38 +++++- .../actions/ConversationIntentDispatcher.ets | 6 +- .../pages/components/ChatMessageBubble.ets | 70 ++++++++-- .../pages/components/ChatMessageChrome.ets | 129 +++++++++++++++++- .../ets/pages/components/ChatTimeline.ets | 10 ++ .../ets/pages/components/ConversationView.ets | 10 +- .../policy/AssistantMessageCopyMenuPolicy.ets | 60 ++++++++ .../runtime/AppRootRuntimeComposition.ets | 6 +- .../viewmodel/ConversationController.ets | 5 +- .../viewmodel/RemoteTranscriptController.ets | 20 ++- .../main/resources/en_US/element/string.json | 12 ++ .../main/resources/zh_CN/element/string.json | 12 ++ .../assistant-message-copy-menu.test.cjs | 109 +++++++++++++++ 13 files changed, 467 insertions(+), 20 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/policy/AssistantMessageCopyMenuPolicy.ets create mode 100644 src/apps/mobile/harmonyos/tools/tests/assistant-message-copy-menu.test.cjs 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/ChatMessageBubble.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ChatMessageBubble.ets index 7ff3e8fc0f..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'; @@ -76,6 +82,8 @@ 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) => {}; @@ -129,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 }) @@ -147,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 }) { @@ -614,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 712b8653cc..d215e045c1 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,5 +1,11 @@ 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, @@ -8,7 +14,19 @@ import { import { UserMessageTruncationPolicy } from '../policy/UserMessageTruncationPolicy'; import { ConversationUiMessage } from '../state/ConversationUiModels'; import { MessageImageGallery } from './ChatMessageContent'; -import { ACCENT, INK, MUTED, CONTENT_ON_ACTION, STATUS_DANGER, SOFT, FILE_LINK } 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 { @@ -183,3 +201,112 @@ export struct ChatUserMessageBubble { 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 125b8ae82b..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,8 @@ 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) => {}; @@ -99,6 +102,7 @@ struct ChatAssistantTimelineRow { onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, onCopyMessage: this.onCopyMessage, + onCopyMessageScoped: this.onCopyMessageScoped, onOpenFullText: this.onOpenFullText, onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, @@ -165,6 +169,8 @@ 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) => {}; @@ -281,6 +287,9 @@ export struct ChatTimeline { onCopyMessage: (text: string) => { this.onCopyMessage(text); }, + onCopyMessageScoped: (text: string, scope: MessageCopyScope) => { + this.onCopyMessageScoped(text, scope); + }, onOpenFullText: (text: string) => { this.onOpenFullText(text); }, @@ -329,6 +338,7 @@ export struct ChatTimeline { onCancelTool: this.onCancelTool, onAnswerQuestion: this.onAnswerQuestion, onCopyMessage: this.onCopyMessage, + onCopyMessageScoped: this.onCopyMessageScoped, onOpenFullText: this.onOpenFullText, onRetryMessage: this.onRetryMessage, onOpenFilePreview: this.onOpenFilePreview, 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 0d376ab9ad..138ce9e1e5 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,7 +9,7 @@ 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'; @@ -402,8 +402,11 @@ export struct ConversationView { onCopyMessage: (text: string) => { this.onCopyMessage(text); }, + onCopyMessageScoped: (text: string, scope: MessageCopyScope) => { + this.onCopyMessageScoped(text, scope); + }, onOpenFullText: (text: string) => { - this.openFullText(text); + this.onOpenFullText(text); }, onRetryMessage: (text: string) => { this.onRetryMessage(text); @@ -671,6 +674,9 @@ 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) { 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/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 23f661bbb3..b68cd646a6 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(); }, 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/resources/en_US/element/string.json b/src/apps/mobile/harmonyos/entry/src/main/resources/en_US/element/string.json index 5be9ed2fab..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 @@ -2256,6 +2256,18 @@ "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 10b6fab42b..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 @@ -2256,6 +2256,18 @@ "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/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); +}); From f80296b4e0a839c02fdea0c54f1712d6a88dc952 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 04:58:44 +0800 Subject: [PATCH 07/13] test(mobile): add copy selection preview fixture One native preview over the production conversation view, with a transcript that carries every shape the copy feature distinguishes: a short user message, an answer holding all of the markdown blocks, and both a line-cut and a character-cut long message. The scenario id picks the mount, because the mount is what device verification has to change: the page root (the wide detail leaf), a pushed Navigation destination (the compact route), and the floating composer. The report strip above the pane states the last copy scope and the pasteboard the copy wrote to, so a screenshot carries the scope and the payload question instead of only the pixels. The placement and the composer presentation follow the real window width, so a phone and a tablet share one scenario. --- .../main/ets/entryability/EntryAbility.ets | 6 +- .../pages/preview/CopySelectionPreview.ets | 405 ++++++++++++++++++ .../ets/pages/preview/MobileDesignGallery.ets | 3 + 3 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/pages/preview/CopySelectionPreview.ets 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..16f73ba1d0 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 @@ -18,6 +18,8 @@ export default class EntryAbility extends UIAbility { 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-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); 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/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 { From 3866440e92cb8f9c8fd013a0af241020c050f8da Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 04:58:52 +0800 Subject: [PATCH 08/13] fix(mobile): keep the full-text affix inside the truncated bubble Measured on device, the truncated bubble's Text declared width('100%'), which resolved against the row the bubble sits in rather than the bubble's own content box: the fill ended at x=1215 of a 1256-wide screen while the Text's box ran to 1256. Only the last line reaches past the fill - the ellipsis and the appended full-text affordance - so that line was laid out outside the bubble and clipped there, leaving the affordance unreadable and only partly tappable in both themes. 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. Verified on device (HcgPhone, light and dark) and on the tablet: the affix is fully visible and opens the panel. --- .../main/ets/pages/components/ChatMessageChrome.ets | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 d215e045c1..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 @@ -157,6 +157,16 @@ export struct ChatUserMessageBubble { * 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() { @@ -173,8 +183,6 @@ export struct ChatUserMessageBubble { } .fontSize(MobileDesignTypography.bodyLarge.size) .lineHeight(MobileDesignTypography.bodyLarge.lineHeight) - .width('100%') - .textAlign(TextAlign.Start) .bindContextMenu(this.TruncatedMenu(), ResponseType.LongPress) } From 249f3f0df9eb6e3c95ac2ffdc09930d90ba33879 Mon Sep 17 00:00:00 2001 From: Tant Date: Fri, 9 Oct 2026 04:58:53 +0800 Subject: [PATCH 09/13] fix(mobile): keep code block chrome out of cross-block selection copy A container selection dragged across a code block copied the block's language label and its copy action into the clipboard payload, so user text came back with 'ts' and the button label inside it. The file's own comment claimed those two labels stayed non-selectable; the device showed otherwise. TextSelectableMode.UNSELECTABLE is what excludes a label from a selection the container drives, and the copy action's click still fires. Verified on device by dragging one handle from the quote into the table and pasting: the payload now carries only the block's code. --- .../src/main/ets/pages/components/MarkdownContent.ets | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 6fcb26369f..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 @@ -307,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); }) @@ -325,8 +327,11 @@ export struct MarkdownContent { .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 non-selectable: - // a long press there would fight the copy button's own click. + // 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) From 74afcdbd2b51367b7de39196558c7696a4e7eddd Mon Sep 17 00:00:00 2001 From: YodonTan Date: Fri, 9 Oct 2026 09:26:26 +0800 Subject: [PATCH 10/13] fix(mobile): harden i18n retranslation and generate the rawfile catalog in the build (#3297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(mobile): add HostTextRetranslator and key mapping tests * fix(mobile): support multi-language retranslation in HostTextRetranslator * feat(mobile): generate i18n rawfile catalog in the hvigor build pipeline Generate entry rawfile/string.json from the en_US/zh_CN element catalogs on every hvigor run: English entries keep their resource names, Chinese entries gain a .zh suffix, and the generated artifact stays ignored like the miniapps rawfiles. The generator fails the build loudly on en/zh key drift and on English names that splitEntryLanguage would parse as a language tag. Fix HostTextRetranslator rawfile decoding: getRawFileContentSync returns UTF-8 bytes, and the previous per-byte fromCharCode loop mangled every Chinese value once a real catalog existed. Decode with util.TextDecoder the same way BuiltinMiniAppHost does. The Node test harness now serves UTF-8 bytes and stubs the @kit.ArkTS util import, so the mock tests exercise the device decode path. Verification: retranslator suite 11/11, tools/tests 276/277 (same pre-existing workspace-editor sidebar failure as before the change), miniapps 6/6, harmony architecture check green; end-to-end probe against the compiled retranslator and the generated rawfile returns Back -> 返回, 返回 -> Back, plus real catalog template flips in both directions; hvigor assembleHap compiles the change and packages the catalog byte-identically, with SignHap blocked only by the intentionally unconfigured local signing identity. * fix(mobile): treat untagged catalog entries as English * fix(mobile): refuse ambiguous values in retranslation like the legacy policy --------- Co-authored-by: Tant --- src/apps/mobile/harmonyos/.gitignore | 3 + src/apps/mobile/harmonyos/entry/hvigorfile.ts | 2 + .../main/ets/i18n/HostTextRetranslator.ets | 244 +++++++++++++++--- .../entry/src/test/I18nUnit.test.ets | 54 ++++ src/apps/mobile/harmonyos/i18n/generate.cjs | 71 +++++ .../tests/host-text-retranslator.test.cjs | 201 +++++++++++++++ 6 files changed, 532 insertions(+), 43 deletions(-) create mode 100644 src/apps/mobile/harmonyos/i18n/generate.cjs create mode 100644 src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs 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/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/i18n/HostTextRetranslator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets index d810c635a6..02cf3dd7c8 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,4 @@ -import { resourceManager } from '@kit.LocalizationKit'; +import { util } from '@kit.ArkTS'; interface TemplateEntry { key: string; @@ -14,65 +14,231 @@ 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. +// +// Per-language reverse indexes identify which catalog language a stored +// message was rendered in; the matched key is then re-rendered in the output +// language: the constructor language when the message matches another +// catalog language, and otherwise the first other catalog language. That way +// a stored string never re-renders in the language it matched. +// +// 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(); } - private loadCatalog(locale: string): void { + 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, language); + if (translated !== null) { + return translated; + } + } + } + + // 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, language, matched); + if (translated !== null) { + return translated; + } + } + } + } + + 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; + } - // 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 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); + } + + 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 outputLanguageFor(sourceLanguage: string): string { + if (sourceLanguage !== this.currentLanguage && this.forwardByLanguage.has(this.currentLanguage)) { + return this.currentLanguage; + } + for (let index = 0; index < this.languages.length; index += 1) { + const language = this.languages[index]; + if (language !== sourceLanguage) { + return language; + } + } + return sourceLanguage; + } + + private renderEntry(baseKey: string, sourceLanguage: string): string | null { + const forward = this.forwardByLanguage.get(this.outputLanguageFor(sourceLanguage)); + if (!forward) { + return null; + } + const value = forward.get(baseKey); + return value !== undefined ? value : null; + } + + private renderTemplate(entry: TemplateEntry, sourceLanguage: string, matched: string[]): string | null { + const forward = this.forwardByLanguage.get(this.outputLanguageFor(sourceLanguage)); + if (!forward) { + return null; + } + const template = forward.get(entry.key); + if (template === undefined) { + return null; + } + if (matched.length === 1) { + return template.replace('%1$s', matched[0]); + } + return template.replace('%1$s', matched[0]).replace('%2$s', matched[1]); } - 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 +256,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 +272,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/test/I18nUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets index 569ad12c3a..8be87880f9 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,7 @@ 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, @@ -109,5 +110,58 @@ 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('has all keys in string.json matching ets catalogs', 0, () => { + restoreZhCn(); + const zhKeys = RemoteI18n.keys(APP_LOCALE_ZH_CN).sort(); + const enKeys = RemoteI18n.keys(APP_LOCALE_EN_US).sort(); + expect(zhKeys.length).assertEqual(enKeys.length); + 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('builds reverse index for O(1) exact matching', 0, () => { + restoreZhCn(); + const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); + const zhBack = RemoteI18n.translate(APP_LOCALE_ZH_CN, 'common.back'); + const enBack = RemoteI18n.translate(APP_LOCALE_EN_US, 'common.back'); + expect(retranslator.retranslate(zhBack)).assertEqual(enBack); + expect(retranslator.retranslate(enBack)).assertEqual(zhBack); + }); + + it('matches templates with %1$s and %2$s', 0, () => { + restoreZhCn(); + const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); + const zhSwitching = RemoteI18n.f('remote.settings.deviceSwitching', 'Desk-1'); + expect(zhSwitching).assertEqual('正在切换到 Desk-1…'); + const enSwitching = retranslator.retranslate(zhSwitching); + expect(enSwitching).assertEqual('Switching to Desk-1…'); + const zhMore = RemoteI18n.f('sidebar.moreSessions', '3'); + expect(zhMore).assertEqual('还有 3 个会话'); + const enMore = retranslator.retranslate(zhMore); + expect(enMore).assertEqual('3 more sessions'); + }); + + 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/host-text-retranslator.test.cjs b/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs new file mode 100644 index 0000000000..c1ac91d412 --- /dev/null +++ b/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs @@ -0,0 +1,201 @@ +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'); + +// Transpile the HostTextRetranslator.ets source +const source = fs.readFileSync( + path.join(__dirname, '../../entry/src/main/ets/i18n/HostTextRetranslator.ets'), + 'utf8' +); +const compiled = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } +}).outputText; + +// Mock the '@kit.ArkTS' util.TextDecoder dependency of the compiled module. +// getRawFileContentSync delivers UTF-8 bytes on device, so the harness must +// decode them the same way instead of assuming one byte equals one character. +function mockKitRequire(name) { + if (name === '@kit.ArkTS') { + return { + util: { + TextDecoder: { + create() { + return { + decodeWithStream(bytes) { + return Buffer.from(bytes).toString('utf8'); + } + }; + } + } + } + }; + } + return {}; +} + +// Mock getContext to return a fake resourceManager with test catalog data +function createMockContext(catalog) { + return { + resourceManager: { + getRawFileContentSync(filename) { + // Serve UTF-8-encoded bytes, matching device rawfile reads. + return Array.from(Buffer.from(JSON.stringify(catalog), 'utf8')); + } + } + }; +} + +// Helper to create a retranslator with a given catalog +function createRetranslator(catalog) { + const exportsObject = {}; + const mockContext = createMockContext(catalog); + const originalGetContext = global.getContext; + global.getContext = () => mockContext; + try { + new Function('require', 'exports', compiled)(mockKitRequire, exportsObject); + // The constructor reads the catalog through getContext, so it must run + // while the override is active. + return new exportsObject.HostTextRetranslator('en-US'); + } finally { + if (originalGetContext === undefined) { + delete global.getContext; + } else { + global.getContext = originalGetContext; + } + } +} + +// Test catalog with single and dual placeholder templates +const testCatalog = { + string: [ + { name: 'common.back', value: 'Back' }, + { name: 'common.back.zh', value: '返回' }, + { name: 'sidebar.moreSessions', value: '%1$s more sessions' }, + { name: 'sidebar.moreSessions.zh', value: '还有 %1$s 个会话' }, + { name: 'remote.settings.deviceSwitching', value: 'Switching to %1$s…' }, + { name: 'remote.settings.deviceSwitching.zh', value: '正在切换到 %1$s…' }, + { name: 'dual.placeholder', value: '%1$s and %2$s' }, + { name: 'dual.placeholder.zh', value: '%1$s 和 %2$s' }, + { name: 'status.notConnected', value: 'Not connected' }, + { name: 'status.notConnected.zh', value: '未连接' }, + // Two distinct keys share one value per language: the value is ambiguous + // and must not be re-rendered through either key. + { name: 'duplicate.first', value: 'Duplicate value' }, + { name: 'duplicate.first.zh', value: '重复值' }, + { name: 'duplicate.second', value: 'Duplicate value' }, + { name: 'duplicate.second.zh', value: '重复值' }, + // The English templates of the two keys collide, so neither key's + // template can identify a match in any language. + { name: 'collision.first', value: '%1$s items' }, + { name: 'collision.first.zh', value: '%1$s 项' }, + { name: 'collision.second', value: '%1$s items' }, + { name: 'collision.second.zh', value: '%1$s 条目' } + ] +}; + +test('exact match returns translated string', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('Back'), '返回'); + assert.equal(retranslator.retranslate('返回'), 'Back'); + assert.equal(retranslator.retranslate('Not connected'), '未连接'); + assert.equal(retranslator.retranslate('未连接'), 'Not connected'); +}); + +test('ambiguous value is returned as-is rather than guessed', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('Duplicate value'), 'Duplicate value'); +}); + +test('ambiguous value refusal applies to every catalog language', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('重复值'), '重复值'); +}); + +test('duplicate catalog values keep unique neighbors translatable', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate('Back'), '返回'); + assert.equal(retranslator.retranslate('返回'), 'Back'); + assert.equal(retranslator.retranslate('Not connected'), '未连接'); +}); + +test('template with an ambiguous value is refused in every language', () => { + const retranslator = createRetranslator(testCatalog); + // collision.first and collision.second share the English value + // '%1$s items', so neither key's template may match, even in Chinese + // where the values differ. + assert.equal(retranslator.retranslate('3 items'), '3 items'); + assert.equal(retranslator.retranslate('3 项'), '3 项'); + assert.equal(retranslator.retranslate('3 条目'), '3 条目'); + // Unambiguous templates keep working. + assert.equal(retranslator.retranslate('3 more sessions'), '还有 3 个会话'); +}); + +test('single placeholder template matches and fills correctly', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese + assert.equal(retranslator.retranslate('3 more sessions'), '还有 3 个会话'); + // Chinese to English + assert.equal(retranslator.retranslate('还有 5 个会话'), '5 more sessions'); +}); + +test('single placeholder with prefix and suffix', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese + assert.equal(retranslator.retranslate('Switching to Desk-1…'), '正在切换到 Desk-1…'); + // Chinese to English + assert.equal(retranslator.retranslate('正在切换到 Desk-2…'), 'Switching to Desk-2…'); +}); + +test('dual placeholder template matches and fills correctly', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese: "foo and bar" -> "foo 和 bar" + assert.equal(retranslator.retranslate('foo and bar'), 'foo 和 bar'); + // Chinese to English: "foo 和 bar" -> "foo and bar" + assert.equal(retranslator.retranslate('foo 和 bar'), 'foo and bar'); +}); + +test('dual placeholder with different values', () => { + const retranslator = createRetranslator(testCatalog); + // English to Chinese + assert.equal(retranslator.retranslate('hello and world'), 'hello 和 world'); + // Chinese to English + assert.equal(retranslator.retranslate('你好 和 世界'), '你好 and 世界'); +}); + +test('returns original message when no match found', () => { + const retranslator = createRetranslator(testCatalog); + const unknown = 'This message does not exist in any catalog'; + assert.equal(retranslator.retranslate(unknown), unknown); +}); + +test('returns empty string for empty input', () => { + const retranslator = createRetranslator(testCatalog); + assert.equal(retranslator.retranslate(''), ''); +}); + +test('template match returns null for non-matching prefix', () => { + const retranslator = createRetranslator(testCatalog); + // "Switched to Desk-1…" does not start with the template prefix "Switching to " + assert.equal(retranslator.retranslate('Switched to Desk-1…'), 'Switched to Desk-1…'); +}); + +test('template match returns null for non-matching suffix', () => { + const retranslator = createRetranslator(testCatalog); + // "3 more sessions xyz" doesn't end with the expected suffix + assert.equal(retranslator.retranslate('3 more sessions xyz'), '3 more sessions xyz'); +}); + +test('dual placeholder with empty middle', () => { + const retranslator = createRetranslator(testCatalog); + // "foo and bar" where middle is " and " + assert.equal(retranslator.retranslate('A and B'), 'A 和 B'); +}); + +test('dual placeholder with complex values', () => { + const retranslator = createRetranslator(testCatalog); + // Values containing spaces + assert.equal(retranslator.retranslate('hello world and foo bar'), 'hello world 和 foo bar'); + assert.equal(retranslator.retranslate('你好世界 和 foo bar'), '你好世界 and foo bar'); +}); From 6b5a9f29a49543a99791bd2ea5bfe046b37ee188 Mon Sep 17 00:00:00 2001 From: Guanqi <51046187+wgqqqqq@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:34:46 +0800 Subject: [PATCH 11/13] fix(harmony): preserve locale and user data during text refresh (#3311) --- src/apps/mobile/harmonyos/AGENTS.md | 5 + .../main/ets/i18n/HostTextRetranslator.ets | 73 ++-- .../src/main/ets/i18n/MessageTemplate.ets | 8 + .../entry/src/main/ets/i18n/RemoteI18n.ets | 23 +- .../runtime/AppRootRuntimeComposition.ets | 8 +- .../entry/src/test/I18nUnit.test.ets | 59 ++-- .../tests/host-text-retranslator.test.cjs | 322 ++++++++---------- 7 files changed, 255 insertions(+), 243 deletions(-) create mode 100644 src/apps/mobile/harmonyos/entry/src/main/ets/i18n/MessageTemplate.ets diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 9d1ada9f07..3a32eeffad 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -76,6 +76,11 @@ source scripts/ohos-env.sh "$HVIGORW" --mode module -p module=entry@default -p ohos.test.type=LocalTest test --no-daemon ``` +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/entry/src/main/ets/i18n/HostTextRetranslator.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/i18n/HostTextRetranslator.ets index 02cf3dd7c8..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 { util } from '@kit.ArkTS'; +import { fillMessageTemplate } from './MessageTemplate'; interface TemplateEntry { key: string; @@ -25,11 +26,8 @@ interface EntryLanguage { // 2-3 letter lowercase dot segment (for example "common.back.zh") tags the // entry language, and untagged entries are the generator's English block. // -// Per-language reverse indexes identify which catalog language a stored -// message was rendered in; the matched key is then re-rendered in the output -// language: the constructor language when the message matches another -// catalog language, and otherwise the first other catalog language. That way -// a stored string never re-renders in the language it matched. +// 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 @@ -61,13 +59,18 @@ export class HostTextRetranslator { } const exactKey = reverse.get(message); if (exactKey !== undefined) { - const translated = this.renderEntry(exactKey, language); + 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]; @@ -79,14 +82,42 @@ export class HostTextRetranslator { const entry = templates[templateIndex]; const matched = this.matchTemplate(entry.template, message); if (matched !== null) { - const translated = this.renderTemplate(entry, language, matched); + const translated = this.renderTemplate(entry, matched); if (translated !== null) { - return translated; + 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; + } + + 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; } @@ -201,21 +232,8 @@ export class HostTextRetranslator { return { key: name, language: 'en' }; } - private outputLanguageFor(sourceLanguage: string): string { - if (sourceLanguage !== this.currentLanguage && this.forwardByLanguage.has(this.currentLanguage)) { - return this.currentLanguage; - } - for (let index = 0; index < this.languages.length; index += 1) { - const language = this.languages[index]; - if (language !== sourceLanguage) { - return language; - } - } - return sourceLanguage; - } - - private renderEntry(baseKey: string, sourceLanguage: string): string | null { - const forward = this.forwardByLanguage.get(this.outputLanguageFor(sourceLanguage)); + private renderEntry(baseKey: string): string | null { + const forward = this.forwardByLanguage.get(this.currentLanguage); if (!forward) { return null; } @@ -223,8 +241,8 @@ export class HostTextRetranslator { return value !== undefined ? value : null; } - private renderTemplate(entry: TemplateEntry, sourceLanguage: string, matched: string[]): string | null { - const forward = this.forwardByLanguage.get(this.outputLanguageFor(sourceLanguage)); + private renderTemplate(entry: TemplateEntry, matched: string[]): string | null { + const forward = this.forwardByLanguage.get(this.currentLanguage); if (!forward) { return null; } @@ -232,10 +250,7 @@ export class HostTextRetranslator { if (template === undefined) { return null; } - if (matched.length === 1) { - return template.replace('%1$s', matched[0]); - } - return template.replace('%1$s', matched[0]).replace('%2$s', matched[1]); + return fillMessageTemplate(template, matched); } private matchTemplate(template: string, message: string): string[] | null { 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/runtime/AppRootRuntimeComposition.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/runtime/AppRootRuntimeComposition.ets index 23f661bbb3..50f6a800f9 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 @@ -993,7 +993,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 +1001,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/test/I18nUnit.test.ets b/src/apps/mobile/harmonyos/entry/src/test/I18nUnit.test.ets index 8be87880f9..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,3 +1,4 @@ +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'; @@ -9,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); } @@ -120,39 +130,34 @@ export default function i18nUnitTest() { expect(RemoteI18n.t('sidebar.moreSessions')).assertEqual('还有 %1$s 个会话'); }); - it('has all keys in string.json matching ets catalogs', 0, () => { + it('keeps generated rawfile catalogs in key lockstep', 0, () => { restoreZhCn(); - const zhKeys = RemoteI18n.keys(APP_LOCALE_ZH_CN).sort(); - const enKeys = RemoteI18n.keys(APP_LOCALE_EN_US).sort(); - expect(zhKeys.length).assertEqual(enKeys.length); + 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(); + expect(zhKeys.indexOf('settings_title') >= 0).assertTrue(); + expect(zhKeys.indexOf('workspaceTools_browse') >= 0).assertTrue(); + expect(zhKeys.indexOf('sidebar_moreSessions') >= 0).assertTrue(); }); }); describe('HostTextRetranslator', () => { - it('builds reverse index for O(1) exact matching', 0, () => { - restoreZhCn(); - const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); - const zhBack = RemoteI18n.translate(APP_LOCALE_ZH_CN, 'common.back'); - const enBack = RemoteI18n.translate(APP_LOCALE_EN_US, 'common.back'); - expect(retranslator.retranslate(zhBack)).assertEqual(enBack); - expect(retranslator.retranslate(enBack)).assertEqual(zhBack); - }); - - it('matches templates with %1$s and %2$s', 0, () => { - restoreZhCn(); - const retranslator = new HostTextRetranslator(APP_LOCALE_ZH_CN); - const zhSwitching = RemoteI18n.f('remote.settings.deviceSwitching', 'Desk-1'); - expect(zhSwitching).assertEqual('正在切换到 Desk-1…'); - const enSwitching = retranslator.retranslate(zhSwitching); - expect(enSwitching).assertEqual('Switching to Desk-1…'); - const zhMore = RemoteI18n.f('sidebar.moreSessions', '3'); - expect(zhMore).assertEqual('还有 3 个会话'); - const enMore = retranslator.retranslate(zhMore); - expect(enMore).assertEqual('3 more sessions'); + 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, () => { 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 index c1ac91d412..c7322ecfe2 100644 --- a/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs +++ b/src/apps/mobile/harmonyos/tools/tests/host-text-retranslator.test.cjs @@ -4,198 +4,168 @@ const fs = require('node:fs'); const path = require('node:path'); const ts = require('typescript'); -// Transpile the HostTextRetranslator.ets source -const source = fs.readFileSync( - path.join(__dirname, '../../entry/src/main/ets/i18n/HostTextRetranslator.ets'), - 'utf8' -); -const compiled = ts.transpileModule(source, { - compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } -}).outputText; - -// Mock the '@kit.ArkTS' util.TextDecoder dependency of the compiled module. -// getRawFileContentSync delivers UTF-8 bytes on device, so the harness must -// decode them the same way instead of assuming one byte equals one character. -function mockKitRequire(name) { - if (name === '@kit.ArkTS') { - return { - util: { - TextDecoder: { - create() { - return { - decodeWithStream(bytes) { - return Buffer.from(bytes).toString('utf8'); - } - }; - } - } - } +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 {}; + return { + create: locale => new (module('HostTextRetranslator').HostTextRetranslator)(locale), + remote: module('RemoteI18n').RemoteI18n, + setResourceLanguage: locale => { language = locale; } + }; } -// Mock getContext to return a fake resourceManager with test catalog data -function createMockContext(catalog) { - return { - resourceManager: { - getRawFileContentSync(filename) { - // Serve UTF-8-encoded bytes, matching device rawfile reads. - return Array.from(Buffer.from(JSON.stringify(catalog), 'utf8')); +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); + } + }); -// Helper to create a retranslator with a given catalog -function createRetranslator(catalog) { - const exportsObject = {}; - const mockContext = createMockContext(catalog); - const originalGetContext = global.getContext; - global.getContext = () => mockContext; - try { - new Function('require', 'exports', compiled)(mockKitRequire, exportsObject); - // The constructor reads the catalog through getContext, so it must run - // while the override is active. - return new exportsObject.HostTextRetranslator('en-US'); - } finally { - if (originalGetContext === undefined) { - delete global.getContext; - } else { - global.getContext = originalGetContext; + 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 catalog with single and dual placeholder templates -const testCatalog = { - string: [ - { name: 'common.back', value: 'Back' }, - { name: 'common.back.zh', value: '返回' }, - { name: 'sidebar.moreSessions', value: '%1$s more sessions' }, - { name: 'sidebar.moreSessions.zh', value: '还有 %1$s 个会话' }, - { name: 'remote.settings.deviceSwitching', value: 'Switching to %1$s…' }, - { name: 'remote.settings.deviceSwitching.zh', value: '正在切换到 %1$s…' }, - { name: 'dual.placeholder', value: '%1$s and %2$s' }, - { name: 'dual.placeholder.zh', value: '%1$s 和 %2$s' }, - { name: 'status.notConnected', value: 'Not connected' }, - { name: 'status.notConnected.zh', value: '未连接' }, - // Two distinct keys share one value per language: the value is ambiguous - // and must not be re-rendered through either key. - { name: 'duplicate.first', value: 'Duplicate value' }, - { name: 'duplicate.first.zh', value: '重复值' }, - { name: 'duplicate.second', value: 'Duplicate value' }, - { name: 'duplicate.second.zh', value: '重复值' }, - // The English templates of the two keys collide, so neither key's - // template can identify a match in any language. - { name: 'collision.first', value: '%1$s items' }, - { name: 'collision.first.zh', value: '%1$s 项' }, - { name: 'collision.second', value: '%1$s items' }, - { name: 'collision.second.zh', value: '%1$s 条目' } - ] -}; - -test('exact match returns translated string', () => { - const retranslator = createRetranslator(testCatalog); - assert.equal(retranslator.retranslate('Back'), '返回'); - assert.equal(retranslator.retranslate('返回'), 'Back'); - assert.equal(retranslator.retranslate('Not connected'), '未连接'); - assert.equal(retranslator.retranslate('未连接'), 'Not connected'); -}); - -test('ambiguous value is returned as-is rather than guessed', () => { - const retranslator = createRetranslator(testCatalog); - assert.equal(retranslator.retranslate('Duplicate value'), 'Duplicate value'); -}); - -test('ambiguous value refusal applies to every catalog language', () => { - const retranslator = createRetranslator(testCatalog); - assert.equal(retranslator.retranslate('重复值'), '重复值'); -}); - -test('duplicate catalog values keep unique neighbors translatable', () => { - const retranslator = createRetranslator(testCatalog); - assert.equal(retranslator.retranslate('Back'), '返回'); - assert.equal(retranslator.retranslate('返回'), 'Back'); - assert.equal(retranslator.retranslate('Not connected'), '未连接'); -}); - -test('template with an ambiguous value is refused in every language', () => { - const retranslator = createRetranslator(testCatalog); - // collision.first and collision.second share the English value - // '%1$s items', so neither key's template may match, even in Chinese - // where the values differ. - assert.equal(retranslator.retranslate('3 items'), '3 items'); - assert.equal(retranslator.retranslate('3 项'), '3 项'); - assert.equal(retranslator.retranslate('3 条目'), '3 条目'); - // Unambiguous templates keep working. - assert.equal(retranslator.retranslate('3 more sessions'), '还有 3 个会话'); -}); - -test('single placeholder template matches and fills correctly', () => { - const retranslator = createRetranslator(testCatalog); - // English to Chinese - assert.equal(retranslator.retranslate('3 more sessions'), '还有 3 个会话'); - // Chinese to English - assert.equal(retranslator.retranslate('还有 5 个会话'), '5 more sessions'); -}); - -test('single placeholder with prefix and suffix', () => { - const retranslator = createRetranslator(testCatalog); - // English to Chinese - assert.equal(retranslator.retranslate('Switching to Desk-1…'), '正在切换到 Desk-1…'); - // Chinese to English - assert.equal(retranslator.retranslate('正在切换到 Desk-2…'), 'Switching to Desk-2…'); -}); - -test('dual placeholder template matches and fills correctly', () => { - const retranslator = createRetranslator(testCatalog); - // English to Chinese: "foo and bar" -> "foo 和 bar" - assert.equal(retranslator.retranslate('foo and bar'), 'foo 和 bar'); - // Chinese to English: "foo 和 bar" -> "foo and bar" - assert.equal(retranslator.retranslate('foo 和 bar'), 'foo and bar'); -}); - -test('dual placeholder with different values', () => { - const retranslator = createRetranslator(testCatalog); - // English to Chinese - assert.equal(retranslator.retranslate('hello and world'), 'hello 和 world'); - // Chinese to English - assert.equal(retranslator.retranslate('你好 和 世界'), '你好 and 世界'); -}); - -test('returns original message when no match found', () => { - const retranslator = createRetranslator(testCatalog); - const unknown = 'This message does not exist in any catalog'; - assert.equal(retranslator.retranslate(unknown), unknown); -}); - -test('returns empty string for empty input', () => { - const retranslator = createRetranslator(testCatalog); - assert.equal(retranslator.retranslate(''), ''); +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('template match returns null for non-matching prefix', () => { - const retranslator = createRetranslator(testCatalog); - // "Switched to Desk-1…" does not start with the template prefix "Switching to " - assert.equal(retranslator.retranslate('Switched to Desk-1…'), 'Switched to Desk-1…'); +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('template match returns null for non-matching suffix', () => { - const retranslator = createRetranslator(testCatalog); - // "3 more sessions xyz" doesn't end with the expected suffix - assert.equal(retranslator.retranslate('3 more sessions xyz'), '3 more sessions xyz'); +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('dual placeholder with empty middle', () => { - const retranslator = createRetranslator(testCatalog); - // "foo and bar" where middle is " and " - assert.equal(retranslator.retranslate('A and B'), 'A 和 B'); +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('dual placeholder with complex values', () => { - const retranslator = createRetranslator(testCatalog); - // Values containing spaces - assert.equal(retranslator.retranslate('hello world and foo bar'), 'hello world 和 foo bar'); - assert.equal(retranslator.retranslate('你好世界 和 foo bar'), '你好世界 and foo bar'); +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'); + } }); From bfcb526df83b1be1df5fc07110cd06dad549c010 Mon Sep 17 00:00:00 2001 From: YodonTan Date: Fri, 9 Oct 2026 09:45:49 +0800 Subject: [PATCH 12/13] feat(mobile): adapt the status bar, navigation bar and camera cutout on all four edges (#3309) * test(mobile): add a dark drawer design-preview fixture The drawer preview scenario was light-only, so the drawer's dark rendering could not be captured on a device (the emulator has no system dark-mode switch, and the preview forces the colour mode from the scenario id). Adds immersive-bottom-drawer-dark next to immersive-bottom-drawer and lets the fixture open the drawer for it, so every drawer verification can now be taken in both colour modes. * fix(mobile): rely on the platform-default transparent system bars The window already runs setWindowLayoutFullScreen(true), and the status bar and navigation area are transparent by platform default, carrying the page's own background through. Setting statusBarColor/navigationBarColor explicitly was therefore redundant; the opaque page colour it replaced was what clipped the drawer title and left a white strip over the notification centre. Keeps only the content colours, which is what the official guidance asks a full-screen app to state. Verified pixel-identical against the explicit transparent version on the emulator (status bar strip 240/30 in light/dark, drawer fill 248/14, title ink at y=245). * feat(mobile): treat the camera cutout as an avoid area The camera cutout is not one of the framework's avoid areas until the module declares `avoid_cutout` (API 12+), so nothing in this app could see it at all: the tree had no CUTOUT reference, and the immersive shell installs the page under the whole window. On a rotated device the cutout moves onto a side edge, where it lands on the drawer panel, the home headers and the composer row - the layouts this app already treats as first-class targets. Declaring the metadata is only what makes the cutout reportable; the padding that consumes it is the next change. The launch log now states the whole area - visibility plus all four rectangles - because it is the one avoid area whose edge changes with rotation, and a device without a cutout has to say so in the evidence rather than stay silent about it. Measured on the bundled phone emulator (1256x2760 px window): the area reports visible=false top=74 bottom=0 left=0 right=0 both with and without the metadata, so this emulator has no cutout to exercise the behaviour with. * feat(mobile): read the window insets on all four edges WindowInsets carried top and bottom only, so even a reported cutout had nowhere to land: the binding could not express the side it rotated onto. More than one avoid area claims an edge - the status bar and a landscape cutout both touch the top, the navigation indicator and a reverse cutout both touch the bottom - and they are overlapping regions of one window rather than strips stacked on each other, so an edge is the widest claim among them and never their sum. That is the merge rule the official handleCutoutAvoidArea sample performs, so it is extracted as a pure function instead of being inlined in the reader: the per-edge maximum is then assertable without a window, and `insetsOf` keeps only the reading and the keyboard rule (which still takes the bottom strip while the keyboard is up, because KeyboardAvoidMode.RESIZE has already lifted the page above it). The binding publishes left/right and three padding primitives. Left and right are per side because a cutout only ever lands on one of them: a full-width band must keep the design's own gutter on the side the device does not claim, while `sidePadding` reserves the wider strip on both sides for a control that has to stay on the window's centre line. * feat(mobile): reserve the side strips wherever a surface owns a side edge A surface whose side edge is the window's edge is what the cutout lands on once the device rotates, so every one of them now consumes the side insets through `leftPadding`/`rightPadding` instead of writing a bare design value: the drawer panel root (which keeps its 20), the compact and wide home headers, the mini-app gallery header, the file preview header, the chat page's header band and composer layer, and the floating master-restore control's x offset. Portrait reports left=right=0, so `max(design, strip)` is exactly the design value there and the geometry is unchanged - the change only exists on a device that rotated its camera onto that side. The audit of the remaining surfaces says the sheets and covers need none of it: bindSheet and bindContentCover inset their own page horizontally, so their rows never reach the window's side edges and a side padding there would only move content that was already clear. That is why SettingsSheet, WorkspaceToolsPanel, SidebarWorkspacePicker, SessionDetailsView, ConversationViewSettings, ConnectView and RemoteSessionList are left alone. The four full-bleed backgrounds - the drawer floor, the account cover's scrim, the chat page's bottom fade and the home dock - also claim SafeAreaType.CUTOUT now. The framework defines SYSTEM as the status bar and the navigation bar only, so a fill that claims the system area alone stops short of a notch and shows the page colour beside it. Their edge lists are unchanged: only the region class the design already declared is widened. The contract test carried the old shapes as exact strings and is updated with them, plus the new four-direction contract: the merge must read TYPE_CUTOUT, the binding must expose left/right and the three paddings, every fill must claim the cutout, the module must declare avoid_cutout, and the edge surfaces must consume the side insets through the binding. * feat(mobile): claim the side edges each full-bleed fill owns The four full-bleed fills already claimed SafeAreaType.CUTOUT, but their edge lists still said TOP/BOTTOM. `expandSafeArea` takes effect per edge and only where the component's own boundary meets that edge's safe-area boundary, so a fill that named the cutout type and then named only the vertical edges claimed nothing at all on the one edge a rotated cutout lands on - the window's left or right. Each fill now names exactly the edges its box owns: drawer floor, account cover scrim all four: both are the window itself; chat page's bottom fade START/END/BOTTOM, in that order, and deliberately no TOP: the layer is only as tall as the status line and the composer and sits at the bottom of the pane, so its top edge is nowhere near the window's top avoid area; home dock BOTTOM only. The dock's own fill exists in the compact composition (`wide()` is false); a side cutout only exists in a window wide enough for `wide()`, which swaps the dock for a centred, 400vp-capped transparent column inside the 40vp gutter Row. A START/END claim there would name an edge whose safe-area boundary that column does not sit on, so it could never fire. Verified on the bundled phone emulator (1256x2760 window, density 3.5). The cutout is 74px and moves as the window rotates: portrait reports left=right=0 with the cutout on top, LANDSCAPE reports left=21.14vp (74px), LANDSCAPE_INVERTED reports right=21.14vp. The drawer's content therefore starts at x=74px in landscape against the design's 70px, and the fills reach the window's edges: the leftmost columns of the landscape drawer are the drawer's own SIDEBAR_BG with no page-colour strip beside them, and the rightmost columns of the reverse rotation are the chat page's PAGE_BG. What the same evidence says about the size of the claim: this emulator does not inset the page for the cutout. The layout dump's root box is the full window, and the pre-change and post-change landscape screenshots are byte-identical, so the added edges change no pixel here and the strip is avoided by the padding the previous change added, not by the fill expanding. The claim is what keeps a device whose page *is* inset from showing the page colour beside the drawer, and it is inert rather than wrong on this one. The contract test carried the old edge lists as exact strings. It is updated, and the new assertion names each fill's edge set with its reason, so the next edit cannot quietly widen a claim to an edge the component does not own. * feat(mobile): subscribe to the window insets once for the whole app Fourteen surfaces each opened their own window. Every `WindowInsetsBinding` ran its own `window.getLastWindow` and registered its own `avoidAreaChange` listener for one fact that belongs to one window, so the app held a subscription per mounted surface, each behind its own asynchronous window call - and the surfaces disagreed with each other for as long as those calls were in flight. `WindowSystemBarService` now owns the one window read and the one listener, shared by every binding through a reference-counted `InsetsBroadcast`: the first registration opens the shared subscription, the last release closes it, and a registration is handed the current value immediately, so a surface that mounts after the last change starts from real numbers instead of zeroes. The broadcast publishes px, the unit the window reports; each binding still converts with its own `UIContext`, because two windows on one device need not share a scale. The public shape the 14+ surfaces are written against is unchanged: `bind(uiContext, context)` and `unbind()` keep their signatures and every padding primitive stays where it was, so no component had to change. Registration is synchronous now, so a component that unmounts immediately after binding has nothing in flight to leak. The case that used to need the component's own `disposed` hand-back - a window read resolving after the last release - is handled inside the broadcast, which also hands back a read that a later open has already superseded; a generation counter is what keeps the second case from installing a listener another open still owns. The one-shot `topSystemInsetPx` read is gone, folded into the binding it was duplicating. Its only consumer was the welcome page's top strip, which read `TYPE_SYSTEM.topRect.height` once at mount: that value cannot follow a rotation, and it misses a portrait camera cutout the merged top strip reports. WelcomeHome reserves its top strip from the binding its dock already reads. Verified on the bundled phone emulator (1256x2760 window, density 3.5) against a build of the parent commit, same scenario (`immersive-bottom-drawer`), same device: portrait `uitest dumpLayout`: 220 nodes, identical apart from the status bar clock (2 nodes) - the drawer panel is [0,137]-[854,2760] in SIDEBAR_BG with its header row at x=70, the design's own 20vp, and the chat page's header band and bottom fade are unchanged; landscape (`-rotation left`, relaunched): 124 nodes, zero differences; the drawer's header row is at x=74, i.e. `max(20vp, 21.14vp)`, the side strip the device reports - the value the previous commit measured for the same scenario. The launch log states the subscription itself once per process, and rotation keeps it connected: `window insets subscription opened` is the only such line, and the values it broadcasts move with the window - `top=38.857 bottom=28 left=0 right=0` in portrait, `top=0 bottom=28 left=21.14 right=0` in landscape, and back to the portrait numbers on a live rotation, with no second subscription. The contract test asserts the count this change is about: one `on(...)` and one `off(...)` in the whole service, the first-registration/last-release reference counting, the immediate value on registration, and the hand-back of a read that resolves late or is superseded. * test(mobile): pin the top bands the status bar content colour follows The window's status bar content colour is one value for the whole window and follows the colour mode alone (`updateSystemBars` in EntryAbility), so it is only correct while every top band the transparent status bar shows through is itself a theme-following page colour. That has been true of every page shipped so far because they all happen to paint `PAGE_BG` / `SIDEBAR_BG` / `PAGE_BG_OVERLAY` / `WELCOME_PAGE_BG` / `CARD` at their top edge - but nothing said so, and nothing would have caught the first page that does not. The comment on `updateSystemBars` now states the coupling and what a new dark-band page owes it: the band and the content colour are one decision, so the page comes back to that method - by declaring its band against the colour mode the way the others do, or by giving the app a per-page override - instead of repainting the band on its own. The contract test enumerates the bands and holds them to it: the drawer panel, the chat header band, the gallery and file preview roots, the compact and wide home roots, the three wide host panes, the connect sheet root and the welcome page. Each must paint its declared semantic page colour, and none of them may paint its top band with `MEDIA_BACKGROUND` / `MEDIA_SCRIM` (dark in both appearances) or with a hard-coded colour. The failure message names the coupling, so the next edit reads what to do rather than only that something moved. No page-level override is introduced, and none is needed yet: there is no second page with a dark top band, and the platform warns against mixing `HdsNavigation.systemBarStyle` with the window-level system bar calls this app uses. When such a page exists, the design decision belongs in `updateSystemBars` with this list as its starting point. * test(mobile): pin the keyboard-avoid mode behind the bottom inset rule `insetsOf` hands the bottom strip to the keyboard while it is visible because `KeyboardAvoidMode.RESIZE` has already lifted the page above it; reserving the navigation indicator as well would count the same strip twice. The mode is set by the chat page and only while that page is mounted, so the rule depends on a page it cannot see, and nothing held that page to it: delete the call and the bottom ends up reserved by the keyboard and the navigation bar at once - the composer and everything docked below the transcript sit behind the keyboard with a bar-sized hole beneath them. ConversationView has a contract assertion for the whole pair now: it keeps the mode it found, sets RESIZE while it is mounted, and restores what it found when it goes. The failure message states the coupling, so the edit that removes the call reads why the inset rule depended on it. `TYPE_SYSTEM_GESTURE` is registered beside the merge, as the decision it is. It is the region a system return gesture can start from, and the official immersive sample reads it with the areas this app merges; this app deliberately does not, because the bottom of that region is already covered by the navigation indicator here and reserving its side part unconditionally would move every full-width band inward on a gesture-navigation device with no measured overlap to justify it. The test keeps the note and the behaviour together: reading the area is a new decision, not a drive-by. `start_window_background` was audited for the same cleanup and is not dead, so it stays. The Android Compose theme consumes it - `surfaceContainerLowest = DarkTokens.StartWindowBackground` in Theme.kt - and the token source emits one token set to every surface at once (scripts/mobile-ui-design-system.mjs), so the HarmonyOS `base`/`dark` colour elements and the generated HarmonyOS and iOS fields that appear to have no reader cannot be dropped without dropping the Android role with them, and removing only the HarmonyOS copies would desync the generated files from their source. * fix(mobile): release the inset listener when the window read fails `observeInsets` registered `avoidAreaChange` and then read the avoid areas, and the read is what throws: a window that cannot report them landed in the catch, which returned an empty release and zeroed every inset. Two things followed, and neither was visible from anywhere else. The listener stayed on the window for the life of the process - `InsetsBroadcast.close()` had nothing to call, so the app held a window listener with no surface mounted - and the shared broadcast was pinned to zeroes, so every later avoid-area change re-threw inside the system's own callback with nobody watching and the whole app laid itself out against zero insets. That is a silent, whole-app degradation, and "degrade loudly" is the rule the remote scenarios are held to. The failure path now takes back exactly the listener it put on, and only if `on` actually registered: releasing blind would detach a callback that is not ours. It logs the failure before falling back to zero insets, and the window-level failure still cannot block page rendering. * test(mobile): count the window subscription across the tree and pin the binding lifecycle The "one window, one listener" contract was counted inside `WindowSystemBarService` alone, so a component in any other file could open `getLastWindow().then(w => w.on('avoidAreaChange', ...))` and nothing noticed - which is exactly the regression the shared broadcast exists to forbid. The count now runs over the whole `entry/src/main/ets` tree, so the assertion covers the file a component would actually be written in. `generated/` is excluded: it is emitted from the design tokens and carries no window code, so a match there would be a generator bug rather than this contract moving. Four lifecycle lines carried the whole "a surface only reads insets while it is mounted, and the app only holds the window while a surface is" rule and had no coverage at all: `unbind` marking the binding disposed, the callback's disposed guard, `close` clearing `opened`, and `bind` releasing its previous registration. Each can be deleted with every other test still green, and the `opened` one is the worst of them: with it left true the broadcast believes it is still open, so after the last surface closes and a new one opens the window listener is never rebuilt and the insets never update again. All four are now asserted by name, with the consequence each deletion has. * fix(mobile): claim the welcome dock's side edges and record where the wide detail pane's side strip lands The dock declared only `SafeAreaEdge.BOTTOM`, on the argument that its fill only exists in the compact composition and a side cutout only exists in a window wide enough for `wide()`. But `wide()` is a logical-width test and a side cutout is not: a landscape window below 600vp, a split screen, a free window and a hover posture all leave the dock sitting on the window's side edges with no side claim, which is the same page-colour sliver this branch fixed in the shell's other three full-bleed fills. The sides are claimed now; in the `wide()` composition the dock paints TRANSPARENT, so the extra claim draws nothing. The wide detail pane's bands were measured in both postures instead of assumed. With the drawer as the master pane the chat page's bands sit at the pane's left edge - x=980 on a 789vp landscape window - so the left strip only indents them: dropping the side padding moves the composer's input 73px while the band's own box does not move at all, and the band's right edge is the window's and does need it. Padding cannot be claimed per edge the way `expandSafeArea` can, and dropping both sides would put the compact header and the flush-left wide composition back under the cutout this strip exists for, so the strip stays reserved and the comments now say which edge is the window's in which parent - plus a note on the wide home header band, whose comment asserted both sides were the window's. The bands a dump has to read are named for it, the way the bottom fade already is. * refactor(mobile): drop the window-service members this branch left without consumers `WindowSystemBarService.setLayoutFullScreen(context, enabled)` had zero call sites: `EntryAbility.onWindowStageCreate` calls `mainWindow.setWindowLayoutFullScreen(true)` on the window it already holds, so the service copy was never reached. `Context`/`window` stay imported for `observeInsets`; no `BusinessError` import existed here. `WindowInsetsBinding.sidePadding(designSpacing)` had zero production consumers either. A cutout lands on one side only, so `max(this.left, this.right)` on both sides would push a full-width band off-centre; the real needs are covered by the per-side `leftPadding()`/`rightPadding()` primitives, and removing it is the user's explicit decision. Its test assertion in `tools/tests/immersive-bottom.test.cjs` was removed with it, along with the entry in the paddings-primitive list and the centred-control rationale in the surrounding comment. That file also counted `window.getLastWindow(` occurrences in the service: the count drops from 2 to 1 now that the full-screen setup is gone, so the assertion and its comment were updated to keep the same contract - the shared inset read is the service's only window read. Verification: harmonyos tools tests 293 cases / 292 pass with only the pre-existing `workspace-editor.test.cjs` sidebar-hierarchy failure; `pnpm run harmony:architecture` clean; `assembleHap` reaches `PackageHap` and produces `entry-emulator-unsigned.hap` (`SignHap` still fails on the pre-existing missing signing config). --------- Co-authored-by: Tant --- .../main/ets/entryability/EntryAbility.ets | 53 +- .../main/ets/pages/components/AppShell.ets | 20 +- .../main/ets/pages/components/AppSidebar.ets | 7 +- .../ets/pages/components/ConversationView.ets | 50 +- .../pages/components/FilePreviewSurface.ets | 11 +- .../ets/pages/components/MiniAppSurface.ets | 7 +- .../main/ets/pages/components/WelcomeHome.ets | 41 +- .../pages/components/WideConversationHost.ets | 6 +- .../components/remote/RemoteSurfaceHost.ets | 36 +- .../pages/preview/ImmersiveBottomPreview.ets | 2 +- .../ets/services/WindowSystemBarService.ets | 324 +++++++++--- .../harmonyos/entry/src/main/module.json5 | 13 + .../tools/tests/immersive-bottom.test.cjs | 496 +++++++++++++++++- 13 files changed, 934 insertions(+), 132 deletions(-) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets index 4d7629cd9a..1ccfa79b77 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/entryability/EntryAbility.ets @@ -13,7 +13,7 @@ export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { const scenarioId = want.parameters?.['openbitfunDesignPreview']; - if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || + if (scenarioId === 'history-scroll' || scenarioId === 'durable-timeline' || scenarioId === 'composer-submit' || scenarioId === 'catalog-refresh' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-dark' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector' || scenarioId === 'device-selector-dark' || scenarioId === 'welcome-home' || scenarioId === 'connected-conversation' || scenarioId === 'streaming-dark' || scenarioId === 'reconnecting-wide' || scenarioId === 'narrow-multiline' || scenarioId === 'fold-context' || scenarioId === 'long-reading' || @@ -25,7 +25,7 @@ export default class EntryAbility extends UIAbility { AppStorage.setOrCreate('scenarioId', scenarioId); } try { - const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' + const colorMode = scenarioId === 'cold-start-home-dark' || scenarioId === 'catalog-refresh-dark' || scenarioId === 'model-role-selector-dark' || scenarioId === 'subagent-process-dark' || scenarioId === 'file-visibility-dark' || scenarioId === 'immersive-bottom-dark' || scenarioId === 'immersive-bottom-drawer-dark' || scenarioId === 'immersive-bottom-settings-dark' || scenarioId === 'composer-overflow-dark' || scenarioId === 'composer-overflow-expanded-dark' || scenarioId === 'device-selector-dark' || scenarioId === 'streaming-dark' || scenarioId === 'interaction-mailbox-dark' ? ConfigurationConstant.ColorMode.COLOR_MODE_DARK : scenarioId === 'cold-start-home' || scenarioId === 'catalog-refresh' || scenarioId === 'model-role-selector' || scenarioId === 'model-role-selector-wide' || scenarioId === 'subagent-process' || scenarioId === 'file-visibility' || scenarioId === 'immersive-bottom' || scenarioId === 'immersive-bottom-wide' || scenarioId === 'immersive-bottom-drawer' || scenarioId === 'immersive-bottom-settings' || scenarioId === 'immersive-bottom-cover' || scenarioId === 'immersive-bottom-miniapps' || scenarioId === 'immersive-bottom-wide-shell' || scenarioId === 'immersive-bottom-tools' || scenarioId === 'immersive-bottom-tools-editor' || scenarioId === 'immersive-bottom-tools-file-action' || scenarioId === 'immersive-bottom-view-settings' || scenarioId === 'immersive-bottom-connect' || scenarioId === 'composer-overflow' || scenarioId === 'composer-overflow-empty' || scenarioId === 'composer-overflow-wide' || scenarioId === 'composer-overflow-expanded' || scenarioId === 'device-selector' || scenarioId === 'connected-conversation' || scenarioId === 'reconnecting-wide' ? ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT @@ -109,7 +109,10 @@ export default class EntryAbility extends UIAbility { /** * The immersive shell is not verifiable from a screenshot alone: whether the * window covers the status bar and how tall the bottom strip is are device - * facts, so state them once per launch. + * facts, so state them once per launch. The camera cutout is stated in full — + * visibility and all four rectangles — because it is the one avoid area that + * moves to a different edge when the device rotates, and a device without a + * cutout reports zeroes here rather than staying silent about it. */ private logWindowInsets(mainWindow: window.Window): void { try { @@ -119,11 +122,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 +161,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/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/ConversationView.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets index 7df81a5de3..08b6d7917c 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/ConversationView.ets @@ -184,10 +184,26 @@ export struct ConversationView { .width('100%') // Carried by the band that owns the background, not by the wrapper: the // band is what has to reach the status bar, and its measured height is - // what the transcript borrows as its content start offset. - .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 +222,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 +248,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. diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets index d225c3d762..88eb84c805 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/FilePreviewSurface.ets @@ -180,8 +180,15 @@ export struct FilePreviewSurface { // The header band is the surface's top edge in both hosts — the compact // full-page overlay and the wide preview pane — so it carries the status bar // strip itself: the band's fill still reaches the top of the window and the - // back control and title sit below the clock and the indicators. - .padding({ left: 8, right: 8, top: 8 + this.insets.top, bottom: 8 }) + // back control and title sit below the clock and the indicators. It spans + // the window horizontally, so it carries the side strips the same way: on a + // rotated device the back control is what the camera cutout would cover. + .padding({ + left: this.insets.leftPadding(8), + right: this.insets.rightPadding(8), + top: 8 + this.insets.top, + bottom: 8 + }) .alignItems(VerticalAlign.Center) .border({ width: { bottom: 1 }, color: LINE }) .backgroundColor(PAGE_BG) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets index 945a403141..65dc7d9f44 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/MiniAppSurface.ets @@ -30,7 +30,9 @@ export struct MiniAppSurface { // The gallery is a full-screen surface, so its header band owns the top // edge of the window: the box grows by the status bar strip and the // controls keep the 56 the design gives them below it, instead of sitting - // under the clock and the indicators. + // under the clock and the indicators. The band spans the window + // horizontally, so it reserves the side strips too — a rotated device puts + // the camera cutout on the back control's edge. Row({ space: 8 }) { NavigationBackButton({ onBack: (): void => { if (this.state.appId) this.actions.back(); @@ -41,7 +43,8 @@ export struct MiniAppSurface { .fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) .textAlign(TextAlign.Center).layoutWeight(1) Blank().width(48) - }.width('100%').height(56 + this.insets.top).padding({ left: 12, right: 12, top: this.insets.top }) + }.width('100%').height(56 + this.insets.top) + .padding({ left: this.insets.leftPadding(12), right: this.insets.rightPadding(12), top: this.insets.top }) if (this.state.document) { MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request }) .layoutWeight(1) diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WelcomeHome.ets index 61fa36977f..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..9f248a3ce8 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/WideConversationHost.ets @@ -136,9 +136,11 @@ export struct WideConversationHost { if (this.wideMasterPaneCollapsed) { // The detail pane's top edge is the window's, so the floating control - // keeps the status bar strip above it instead of sitting under it. + // keeps the status bar strip above it instead of sitting under it — and + // its left edge is the window's too, which is the edge a rotated device + // moves the camera cutout onto. SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane }) - .position({ x: 12, y: 12 + this.insets.top }).zIndex(2) + .position({ x: this.insets.leftPadding(12), y: 12 + this.insets.top }).zIndex(2) .hitTestBehavior(HitTestMode.Default) } } diff --git a/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets index dff71d963e..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/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/services/WindowSystemBarService.ets b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets index ac9c0c684c..ab21f4d706 100644 --- a/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets +++ b/src/apps/mobile/harmonyos/entry/src/main/ets/services/WindowSystemBarService.ets @@ -2,98 +2,275 @@ 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 => { + 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 +278,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 +337,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/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'); +}); From 6937264a9d7b4fd7148a014aaf68e7be3d110946 Mon Sep 17 00:00:00 2001 From: Guanqi <51046187+wgqqqqq@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:54:45 +0800 Subject: [PATCH 13/13] fix(harmony): ignore stale window inset callbacks (#3312) --- src/apps/mobile/harmonyos/AGENTS.md | 5 + .../ets/services/WindowSystemBarService.ets | 6 + .../tests/window-insets-lifecycle.test.cjs | 105 ++++++++++++++++++ 3 files changed, 116 insertions(+) create mode 100644 src/apps/mobile/harmonyos/tools/tests/window-insets-lifecycle.test.cjs diff --git a/src/apps/mobile/harmonyos/AGENTS.md b/src/apps/mobile/harmonyos/AGENTS.md index 3a32eeffad..a922e920dc 100644 --- a/src/apps/mobile/harmonyos/AGENTS.md +++ b/src/apps/mobile/harmonyos/AGENTS.md @@ -76,6 +76,11 @@ 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 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 ab21f4d706..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 @@ -241,6 +241,12 @@ class InsetsBroadcast { 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); 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); +});